Webhook endpoint — import an offer
The webhook endpoint creates a single offer as a draft in your Flino organisation. It is idempotent over the combination external_source + external_id, so the same call sent multiple times does not produce a duplicate.
Endpoint
Section titled “Endpoint”POST https://<your-project>.supabase.co/functions/v1/zapier-offer-importYou find the <your-project> part in Settings → Integrations → API tokens, in the “Zapier webhook URL” section.
This Supabase Functions URL is the canonical and permanent endpoint address for the webhook. A separate API subdomain (e.g. api.flino.io) is not planned at the moment — use the URL shown here as the stable address.
Authentication
Section titled “Authentication”Headers:
x-flino-token: <your-api-token>Content-Type: application/jsonGenerate <your-api-token> as described in API tokens. Per token the scope is fixed to offer_import:write.
Method
Section titled “Method”Only POST is accepted. Other methods return 405 Method not allowed.
Request body — fields
Section titled “Request body — fields”The body must be a JSON object. Arrays or strings are rejected with 400 Request body must be a JSON object.
Required fields
Section titled “Required fields”| Field | Type | Description |
|---|---|---|
title | string, non-empty | Offer title, e.g. “Website redesign Mustermann GmbH” |
external_id | string, non-empty | Unique ID from your system — the key for idempotency |
external_source | string, non-empty | Source system, e.g. zoho, pipedrive, zapier, myform — stored internally in lowercase |
Optional fields
Section titled “Optional fields”| Field | Type | Description / default |
|---|---|---|
amount | number | null | Offer amount in main currency unit (e.g. 1500.00) |
currency | string | EUR (default), USD, or CHF. Other values are rejected with 400. |
contact_name | string | null | Full name. Flino splits it automatically: the last word becomes the last name, the rest the first name. Only used together with contact_email. |
contact_email | string | null | Must be in valid email format. Surrounding spaces are removed, it is stored in lowercase. Without contact_email the offer gets no contact. |
company_name | string | null | Company name; Flino creates a new company or reuses an existing one |
offer_number | string | null | Your own offer number |
valid_until | string | null | ISO date, e.g. 2026-12-31. Invalid dates are ignored (value stays null). |
Consumed / derived
Section titled “Consumed / derived”- Salutation and language are not transmitted in the Phase 1 webhook — they are added on the contact manually or in the first send dialog.
- Address is not transmitted in the Phase 1 webhook.
Idempotency
Section titled “Idempotency”For each call, Flino checks whether an offer with the same external_source + external_id already exists in your organisation.
- If it exists: no new offer, response
200withcreated: false, the existingoffer_id, and its currentstatus. Such a call does not use a place in your offer quota. - If it does not exist: a new offer is created, response
200withcreated: true.
Useful when your source system retries — you don’t end up with duplicates.
Offer quota
Section titled “Offer quota”An offer newly created via the webhook counts against your plan’s offer quota just like an offer created in the app (see Billing). Flino reserves the place before the offer is created. If you delete the draft without sending it, the place becomes free again.
If the quota is used up, Flino creates nothing and responds with 403:
{ "success": false, "error": "OFFER_QUOTA_EXHAUSTED", "code": "OFFER_QUOTA_EXHAUSTED", "message": "Dein Angebotskontingent für diesen Monat ist aufgebraucht. Es erneuert sich mit dem nächsten Abrechnungszeitraum. Mehr Kontingent gibt es mit einem höheren Plan."}The message is always German. The response also contains a balances field with the current state. Send the same call again after the quota renews or after switching to a larger plan; thanks to idempotency this does not create a duplicate.
Success response
Section titled “Success response”Status 200 OK:
{ "success": true, "created": true, "offer_id": "8d3f7e09-1e0e-44e5-b6c3-49e3a7c2a131", "status": "draft"}On idempotency hit:
{ "success": true, "created": false, "offer_id": "8d3f7e09-1e0e-44e5-b6c3-49e3a7c2a131", "status": "draft"}Full example
Section titled “Full example”Request
Section titled “Request”POST /functions/v1/zapier-offer-import HTTP/1.1Host: <your-project>.supabase.coContent-Type: application/jsonx-flino-token: 9a3f...c1e2
{ "title": "Website redesign Mustermann GmbH", "amount": 4900, "currency": "EUR", "contact_name": "Anna Mustermann", "company_name": "Mustermann GmbH", "offer_number": "ANG-2026-0042", "valid_until": "2026-06-30", "external_id": "deal_19238", "external_source": "zoho"}Response
Section titled “Response”{ "success": true, "created": true, "offer_id": "8d3f7e09-1e0e-44e5-b6c3-49e3a7c2a131", "status": "draft"}Error cases
Section titled “Error cases”| HTTP status | Body error | Meaning |
|---|---|---|
400 | Invalid JSON body | Body is not valid JSON |
400 | Request body must be a JSON object | Body is an array or a string |
400 | Field 'title' is required and must be a non-empty string | title missing or empty |
400 | Field 'external_id' is required and must be a non-empty string | external_id missing or empty |
400 | Field 'external_source' is required and must be a non-empty string | external_source missing or empty |
400 | Field 'currency' must be one of: EUR, USD, CHF | unsupported currency |
400 | Field 'amount' must be a number | amount sent as string or boolean |
400 | Field 'contact_email' is not a valid email address | invalid email format |
401 | Missing x-flino-token header | Header missing |
401 | Invalid or unknown token | Token not found |
401 | Token has been revoked | Token has been revoked |
403 | Token lacks required scope: offer_import:write | Scope mismatch |
403 | OFFER_QUOTA_EXHAUSTED | Offer quota used up, see above |
405 | Method not allowed | Only POST allowed |
429 | Too many requests | Limit exceeded, see Rate limits |
500 | Organisation owner not found | Org configuration issue — contact support |
500 | Failed to create offer: ... | Database error |
503 | Offer quota reservation failed | The quota could not be checked just now, nothing was created; send again later |
All error responses follow this schema:
{ "success": false, "error": "<plain-text description>"}Some errors also carry a code field for automated handling: RATE_LIMITED (429), OFFER_QUOTA_EXHAUSTED (403), and OFFER_QUOTA_ERROR (503).
What Flino does with your call
Section titled “What Flino does with your call”- Validate the token — SHA-256 lookup, revoke check, scope check, then the per-token limit.
- Check the body — required fields, currency, amount, email format.
- Check idempotency — does the offer already exist? If so, return the existing one.
- Create or reuse the company — if
company_nameis set, Flino looks for an existing company with the same name in your org; upper/lower case and legal-form suffixes such as “GmbH” or “AG” are ignored. If none, a new one is created. - Create or reuse the contact — only if
contact_emailis set, with the email address as the key. For an existing contact Flino only fills empty fields (such as the name) and never overwrites anything. Withoutcontact_emailthe offer has no contact. - Reserve quota — one place in the offer quota, see above.
- Create the offer as
draft— with send statusnot_sent, sourcezapier, yourexternal_idandexternal_sourcefor the next idempotency check.
What happens after the import
Section titled “What happens after the import”- The offer appears under Offers with the note Zapier import and in the Pipeline with status New.
- You open it manually, fill in missing data (contact, salutation, email address, description), and send it from Flino as usual.
- No sequence is started, no email is sent — the webhook only creates the offer.
Rate limits
Section titled “Rate limits”Each token may send 60 requests per minute. Beyond that, the endpoint responds with 429:
{ "success": false, "error": "Too many requests", "code": "RATE_LIMITED"}Wait until the next minute and send the call again. Requests with unknown tokens also share a common limit; once it is exceeded, they get 429 instead of 401 as well.
Retries are harmless: idempotency via external_source + external_id makes sure a repeated call does not create a duplicate offer.
If you plan a bulk import (several hundred offers at once), spread the calls out below the limit and check beforehand whether your offer quota covers it.