REST API v1 Β· Resource
Webhooks
Register signed webhooks, the events they carry, verifying a signature, deliveries and redelivery.
API reference Β· Webhooks
Endpoints
Paths are relative to https://fliok.com/api/v1. Each endpoint names the scope and the lowest key role it needs; a key without them gets 403 (see Authentication).
Webhooks
10- GET
/webhooksList your registered webhook endpoints. Except for an admin key with no scopes of its own, each address shows only its scheme and host β apart from the ones this key registeredScopewebhooks:readΒ· viewer role or higherNeeds an API key - POST
/webhooksRegister an endpoint for every event (admin keys with no scopes of their own), a list (events) or just one (event). Returns its id and, once, the signing secret. Never a Fliok addressScopewebhooks:manageΒ· admin role or higherNeeds an API key - GET
/webhooks/eventsEvery event you can subscribe to, with its meaning and payload fieldsScopewebhooks:readΒ· viewer role or higherNeeds an API key - GET
/webhooks/samples/{event}Your latest real deliveries of one event (admin keys with no scopes of their own, or a webhooks:manage key that may read the event), or a documented example when there are none yetScopewebhooks:readΒ· viewer role or higherNeeds an API key - PATCH
/webhooks/{id}Pause/resume an endpoint, change its subscribed events, or move it to a new address (checked like a registration)Scopewebhooks:manageΒ· admin role or higherNeeds an API key - POST
/webhooks/{id}/rotateReplace the signing secret; the old one keeps signing alongside it for 24 hoursScopewebhooks:manageΒ· admin role or higherNeeds an API key - DELETE
/webhooks/{id}Remove a webhook endpoint. Safe to repeat: one already gone answers ok with deleted: falseScopewebhooks:manageΒ· admin role or higherNeeds an API key - GET
/webhooks/{id}/deliveriesThe endpointβs delivery attempts, cursor-paginated: status, latency, error and the data sent (kept 7 days; a key narrowed to scopes sees only the data of events it may read)Scopewebhooks:manageΒ· admin role or higherNeeds an API key - POST
/webhooks/{id}/testSend a signed test event to the endpoint once; the result shows in its deliveriesScopewebhooks:manageΒ· admin role or higherNeeds an API key - POST
/webhooks/deliveries/{id}/redeliverSend a logged delivery again, with the eventβs original id and time, signed with the current secretScopewebhooks:manageΒ· admin role or higherNeeds an API key
Webhook events
Register a webhook URL and Fliok POSTs to it whenever an event occurs. 43 events are emitted today. Every payload is signed with HMAC-SHA256 using your endpoint secret, and every body has the same shape: { id, event, createdAt, data } β createdAt is when the event happened, the same on every retry. Non-2xx responses are retried with backoff (12 attempts over about 17 hours), and an endpoint that keeps failing is disabled automatically.
The invoice.*, quotation.*, booking.* and review.received events come from Invoices and Bookings, and fire for a workspace that has switched them on. Each is written down in the same step as the fact itself, so it is sent once whoever caused it β a team member, your payment gateway, or the customer on their own page β a few seconds later. Every amount comes twice: in minor units (paise for INR) for arithmetic, and as the text printed on the document. Registering a webhook needs an admin key for these money events (a no-code key with webhooks:manage can subscribe to bookings and reviews, never to invoices or quotations), so they never reach an agent-role key.
- De-duplicate on the event id. Delivery is at least once: a retry, or a redelivery from the dashboard, carries the same
x-webhook-id(and bodyid). Store the ids you have handled and skip a repeat. - A customer may have no phone number. WhatsApp lets people use a username, and then shares no number for them. Their
phoneiswa:<user id>instead of digits, and events that name a contact also carryuserId(their WhatsApp user id) andusernamewhenever WhatsApp sent them β for customers with a number too. To answer, pass thatphonetoPOST /sendastoexactly as you received it, or sendrecipient: userId. Donβt treatphoneas digits. - Reject stale requests. Refuse a delivery whose
x-webhook-timestamp(unix seconds, when this attempt was signed) is more than five minutes from your clock, so a captured request canβt be replayed later. - Rotate without downtime.
POST /webhooks/{id}/rotate(or Rotate secret on the API keys page) returns a new secret once. For the next 24 hoursx-webhook-signaturecarries two signatures,sha256=<new>,sha256=<old>: accept the delivery when any one matches, switch your server to the new secret, and the old one stops after the 24 hours. - See what was sent. The API keys page keeps every attempt for 7 days β status, timing, error and the payload β and can send any of them again.
Choose what you receive with the events array when you register or PATCH an endpoint β for example ["message.received","message.failed"], or a single event β what a no-code tool subscribing one URL per automation sends. Leave both out (the default) to receive every event, including ones added later. The x-webhook-event header always names the event. GET /webhooks/samples/{event} shows what a delivery of an event looks like before one has happened. Using Zapier, Make or Pabbly? See Zapier, Make and Pabbly Connect.
| Event | Description | Payload |
|---|---|---|
message.received | A contact sent a message to your number. Deduplicated β Meta redelivers webhooks, you do not get doubles. | messageId, wamid, conversationId, contact, type, content |
message.status | An outbound message changed state: sent, delivered, read or failed. Each state fires at most once; statuses never move backwards. | messageId, wamid, conversationId, status, errors |
message.failed | An outbound message failed for good, with the Meta error code explained. Fires once per message. Messages we deliberately did not send (status "skipped") never appear here. | messageId, wamid, conversationId, contact, type, category, errorCode, errorTitle, errorMessage, permanent, retryable, failedAt |
template.approved | Meta approved a message template. Fired on the transition into approved, so a redelivered Meta webhook does not re-fire it. | templateId, metaTemplateId, name, language, category, status |
template.rejected | Meta rejected a message template. `reason` carries Meta's rejection reason when it supplies one. | templateId, metaTemplateId, name, language, category, status, reason |
template.category_changed | Meta re-categorised a template by itself (e.g. UTILITY β MARKETING). Fired on the transition, not on webhook redelivery. Worth acting on: the category decides the per-message price and whether a marketing opt-out suppresses the send. | templateId, metaTemplateId, name, language, previousCategory, category |
contact.created | A customer became a contact for the first time. `phone` is their number β or `wa:<user id>` for a WhatsApp customer whose number Meta has not shared. `userId` and `username` (their WhatsApp user id and username) are present only when known. `source` is inbound_message, smb_echo, user_preferences, api_send or api (POST /contacts and /contacts/bulk). Bulk paths β CSV import, dashboard-created contacts, coexistence address-book sync and Coexistence history backfill β do NOT emit this. | contactId, phone, name, userId, username, source, createdAt |
contact.updated | A person changed a contact in the dashboard or through the API: name, tags, custom fields, opt-in or WhatsApp block. `changed` lists what the request set (displayName, tags, attributes, optInStatus, optInSource, blocked); `tags`, `optInStatus` and `blocked` are the contact as it is now; `via` is dashboard or api. What the platform records by itself (an inbound message, the assistant's notes, a CSV import) does not fire it. | contactId, phone, name, changed, tags, optInStatus, blocked, via, updatedAt |
contact.deleted | A contact was erased β with its chats, messages and lead history β from the dashboard or the API (`via`). `phone` is the number it had (or `wa:<user id>`), so your system can erase its own copy of the same person. | contactId, phone, via, deletedAt |
conversation.opened | The 24-hour customer-service window opened: a customer message arrived while the window was closed. Free-form messages are allowed until windowExpiresAt. Later messages inside the same window do not re-fire it. | conversationId, contact, windowExpiresAt, freeEntryExpiresAt, openedAt |
conversation.closed | The 24-hour customer-service window expired. Only approved templates can be sent until the customer messages again. Detected within ~5 minutes of expiry. | conversationId, contact, windowExpiredAt, closedAt |
conversation.rated | A customer answered the satisfaction survey on a resolved conversation. `score` is 1-5. Fired once per conversation; later taps are ignored. | conversationId, score |
flow.completed | A customer submitted a WhatsApp Flow (an in-chat form). `fields` carries their answers keyed by the Flow's own field names; Meta's flow_token is removed. Fires however the form was sent β by hand, by the AI, by a bot flow or from the API. | messageId, contactId, fields |
call.missed | A customer called the business number on WhatsApp and nobody answered. Fires once per call, on the transition to missed. | callId, contactId, conversationId, phone, startedAt |
order.cod_response | A customer answered the cash-on-delivery confirmation for an order. `confirmed` false means they disowned it β hold the parcel. | conversationId, contactId, reference, confirmed |
order.event | A connected Shopify or WooCommerce store reported an order event. `type` is order_placed, order_paid, order_shipped or order_cancelled. Fires once per store, event and order, whether or not a WhatsApp template is set for that event; the store's retries do not fire it again. | type, provider, shopId, reference, contactId, phone, customerName, total, currency, url, itemCount, cashOnDelivery, occurredAt |
broadcast.completed | A broadcast finished fanning out β every recipient is queued, skipped or excluded. Per-recipient delivery continues and is reported by message.status. | broadcastId, name, templateName, totals, recipients, startedAt, completedAt |
lead.created | A new lead entered the CRM. `via` is form, api, import, manual, integration or whatsapp. `lead` carries id, phone, name, email, stage {id,name,kind}, owner, source, rating, dealValue and tags. | lead, source, via, form, createdAt |
lead.recaptured | A number already in the CRM arrived again (a second form fill, a re-import). The lead is not duplicated; `captureCount` says how many times it has come in. | lead, source, via, form, captureCount, capturedAt |
form.submitted | Somebody submitted a form to your lead-capture link (a website form, or a marketplace push such as IndiaMART or JustDial). Every submission fires, including a repeat from a number you already have (`created` false); a portal re-sending the same enquiry does not. `note` is what they wrote in the message box, `fields` holds answers matched to your custom fields, `extra` everything else the form sent. | leadId, created, phone, name, email, form, source, via, note, fields, extra, submittedAt |
lead.stage_changed | A lead moved stage. `from`/`to` are {id,name,kind} with kind initial, active, won or lost; `lostReason` is set on a move to a lost stage. Restructuring the pipeline in settings does not fire this. | lead, from, to, lostReason, changedAt |
lead.assigned | A lead's owner changed β by a person, a routing rule, an automation or inbox auto-assign. `to` is null when the lead was unassigned. | lead, from, to, assignedAt |
task.created | A follow-up task was created on a lead. `task` carries id, title, type, dueAt, priority, status and assignee. | task, lead, createdAt |
task.completed | A task on a lead was marked done, with the outcome the person recorded. | task, lead, outcome, completedAt |
instagram.message.received | A customer sent an Instagram Direct message. `contact` is {id, name, username} β Instagram has no phone number. A message with several photos arrives as one event per photo (its words are the first one's caption). Deduplicated like message.received. | messageId, mid, conversationId, contact, type, content, receivedAt |
instagram.comment.received | A new comment on one of your Instagram posts, a reply in a comment thread, or an @mention (`kind` is comment, reply or mention). `commentId` is the id the /instagram/comments endpoints take. Your own replies never fire it. | commentId, kind, igCommentId, igMediaId, username, text, permalink, commentedAt |
instagram.post.published | A post written in Fliok went out on Instagram β on its schedule or posted now. Fires once per post; a failure fires nothing (check the post's status). | postId, kind, caption, igMediaId, permalink, scheduledAt, publishedAt |
payment.paid | A customer paid a WhatsApp Pay request. Amounts are in paise (INR); `paymentMethod` is Meta's method type (upi, card, netbanking, walletβ¦) when it says. If `paidPaise` differs from `totalPaise`, Meta reported a different amount than was asked: check the payment before you fulfil the order. Fired on the transition into paid, so Meta's repeated payment updates never fire it twice. | paymentRequestId, referenceId, conversationId, contactId, totalPaise, paidPaise, currency, paymentMethod, paidAt |
invoice.issued | An invoice or a proforma was issued in Invoices. `invoice` carries id, kind (invoice or proforma), number, status, version, issueDate, dueDate, validUntil, currency, the totals in minor units (taxableMinor, taxMinor, totalMinor, receivedMinor, tdsMinor, balanceMinor) with `total` and `balance` as printed text, `customer` {contactId, name, phone, email} and `url`, the page the customer was sent. Totals only: the lines are on the document. `customer.phone` is null when the document carries no number. `bookingId` is set when the document is the receipt of a booking's advance β that money is the booking's own `advance`, so count it once β and null otherwise. Fires once per document; an invoice made from a proforma names it in `fromProforma`, and arrives already `paid` when the proforma was. | invoice, fromProforma, issuedAt |
invoice.updated | An issued invoice or proforma was edited: the same number, a new `version`. `changed` names what is different on the printed document (for example "Total", "Due date") β never the values; `invoice` is the document as it now stands. | invoice, changed, updatedAt |
invoice.payment_recorded | Money was recorded on an invoice or a proforma β by a person, or reported by your own payment gateway. `payment` carries id, receiptNumber, amountMinor and `amount` as text, tdsMinor, currency, paidOn, method, reference and source (manual or gateway); `invoice` is the document after this payment. One event per payment: a gateway reporting the same payment again fires nothing. | payment, invoice, recordedAt |
invoice.paid | An invoice or a proforma became paid in full. Fires on the payment that closed the balance (`paymentId`), alongside that payment's own invoice.payment_recorded. If a payment is voided and the balance is paid again later, it fires again. | invoice, paymentId, paidAt |
invoice.payment_voided | A payment on an invoice or a proforma was voided: it no longer counts and the receipt keeps its number. `invoice` shows the balance that is owed again; `reason` is what the person who voided it wrote. | payment, invoice, reason, voidedAt |
invoice.cancelled | An issued invoice or proforma was cancelled. It keeps its number, and nothing is owed on it. `reason` is what the person who cancelled it wrote. | invoice, reason, cancelledAt |
quotation.accepted | A quotation was accepted β by the customer on the quotation's page, or marked accepted by a team member (`acceptedBy` is customer or staff). `quotation` carries id, number, status, version, issueDate, validUntil, currency, totalMinor with `total` as text (the optional items the customer chose are in it), `customer` and `url`. A revised quotation that is accepted again fires again. | quotation, acceptedBy, acceptedAt |
quotation.declined | The customer declined a quotation on its page. `reason` is what they wrote, when they wrote anything. | quotation, reason, declinedAt |
booking.created | A booking was made in Bookings β by a customer on your booking page, or added by a team member (`by` is customer or staff). Where a service asks for an advance to hold the slot, this fires when the booking is confirmed β the advance was paid, or your team confirmed it without one β and never while the slot is only being held; a held slot that is given up or runs out of time fires nothing. Once per booking. `booking` carries id, service, `summary` (the service as the customer reads it: "Haircut with Riya", "Badminton β Court 1"), `staff` {id, name, kind: person or resource} or null, startAt and endAt (ISO, UTC) with the calendar's `timeZone`, durationMinutes, status, locationKind, location, priceMinor with `price` as text (the price shown, never charged by the booking), currency, `advance` {amountMinor, amount, currency, status, paidAt, invoiceId} or null, `customer` {contactId, name, phone, email} and source (public or staff). `customer.phone` is null when the booking carries no number. | booking, by, createdAt |
booking.rescheduled | A booking was moved to another time, by the customer from their link or by a team member. `booking` carries the new time, `previousStartAt` the one it had. | booking, previousStartAt, by, rescheduledAt |
booking.cancelled | A booking was cancelled β by the customer from their link, or by a team member. `reason` is what they gave, when they gave one. `booking.advance.status` says what became of an advance that was paid: refund_due or kept. | booking, by, reason, cancelledAt |
booking.completed | A team member marked a booking as completed: the visit happened. | booking, completedAt |
booking.no_show | A team member marked a booking as a no-show: the customer did not come. | booking, markedAt |
booking.restored | A booking is confirmed again: a cancelled booking was brought back, or completed / no-show was undone. `from` is the status it had (cancelled, completed or no_show). | booking, from, restoredAt |
review.received | A customer answered a review request sent after an invoice was paid or a visit was completed. `rating` is 1-5 and `comment` what they wrote; `about` is invoice or booking, with its id in `subjectId` and the invoice's number or the service's name in `subjectLabel`. `low` is true when the rating is at or under your own tell-the-team line. Fires once per request. | reviewId, rating, comment, low, about, subjectId, subjectLabel, contactId, customerName, answeredAt |
Verifying webhook signatures
Hash the raw body with your endpointβs secret and compare. The example is Node.js with Express; the steps are the same in any language.
const crypto = require('crypto');
// Headers on every delivery:
// x-webhook-id, x-webhook-event, x-webhook-timestamp, x-webhook-signature
// The signature is HMAC-SHA256 over `${timestamp}.${rawBody}`, hex, prefixed "sha256=".
// For 24 hours after a secret is rotated the header carries two, comma-separated:
// "sha256=<new>,sha256=<old>" β accept the delivery when any one matches.
// You MUST hash the RAW body β re-serialising the parsed JSON will not match.
function verifyWebhook(rawBody, headers, secret) {
const ts = headers['x-webhook-timestamp'];
// Refuse anything signed more than 5 minutes away from now (a replayed request).
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret) // the whsec_β¦ shown once on creation
.update(ts + '.' + rawBody)
.digest('hex');
return (headers['x-webhook-signature'] || '').split(',').some((got) =>
expected.length === got.trim().length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got.trim())));
}
// In your Express route (note express.raw, not express.json):
app.post('/webhooks/fliok', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body.toString(), req.headers, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { id, event, createdAt, data } = JSON.parse(req.body.toString());
// Delivered at least once: skip an id you have already handled.
// event is one of message.received, message.status, message.failed, template.approved, template.rejected, template.category_changed, contact.created, contact.updated, contact.deleted, conversation.opened, conversation.closed, conversation.rated, flow.completed, call.missed, order.cod_response, order.event, broadcast.completed, lead.created, lead.recaptured, form.submitted, lead.stage_changed, lead.assigned, task.created, task.completed, instagram.message.received, instagram.comment.received, instagram.post.published, payment.paid, invoice.issued, invoice.updated, invoice.payment_recorded, invoice.paid, invoice.payment_voided, invoice.cancelled, quotation.accepted, quotation.declined, booking.created, booking.rescheduled, booking.cancelled, booking.completed, booking.no_show, booking.restored, review.received
// Always switch on `event` and ignore ones you do not handle β new events may be added.
res.json({ received: true });
});Rate limits
The buckets these endpoints use. Burst is how many requests you can make back-to-back from a full bucket; sustained is how fast it refills.
| Bucket | Endpoints | Burst | Sustained |
|---|---|---|---|
| Reads | /messages Β· /conversations Β· /contacts Β· /templates Β· /phone-numbers Β· GET /webhooks Β· Instagram reads Β· GET /payment-requests/{id} | 600 | 60 / sec |
| Webhook admin | POST /webhooks | 60 | 30 / sec |
| Webhook changes | PATCH /webhooks/{id} Β· DELETE /webhooks/{id} | 60 | 1 / sec |
| Webhook secret rotation | POST /webhooks/{id}/rotate | 5 | 5 / hour |
Every limit, the RateLimit-* headers and what to do on a 429: Rate limits.