Skip to content

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 registeredScope webhooks:read Β· viewer role or higher
  • 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 addressScope webhooks:manage Β· admin role or higher
  • GET/webhooks/eventsEvery event you can subscribe to, with its meaning and payload fieldsScope webhooks:read Β· viewer role or higher
  • 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 yetScope webhooks:read Β· viewer role or higher
  • PATCH/webhooks/{id}Pause/resume an endpoint, change its subscribed events, or move it to a new address (checked like a registration)Scope webhooks:manage Β· admin role or higher
  • POST/webhooks/{id}/rotateReplace the signing secret; the old one keeps signing alongside it for 24 hoursScope webhooks:manage Β· admin role or higher
  • DELETE/webhooks/{id}Remove a webhook endpoint. Safe to repeat: one already gone answers ok with deleted: falseScope webhooks:manage Β· admin role or higher
  • 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)Scope webhooks:manage Β· admin role or higher
  • POST/webhooks/{id}/testSend a signed test event to the endpoint once; the result shows in its deliveriesScope webhooks:manage Β· admin role or higher
  • POST/webhooks/deliveries/{id}/redeliverSend a logged delivery again, with the event’s original id and time, signed with the current secretScope webhooks:manage Β· admin role or higher

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 body id). 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 phone is wa:<user id> instead of digits, and events that name a contact also carry userId (their WhatsApp user id) and username whenever WhatsApp sent them β€” for customers with a number too. To answer, pass that phone to POST /send as to exactly as you received it, or send recipient: userId. Don’t treat phone as 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 hours x-webhook-signature carries 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.

EventDescriptionPayload
message.receivedA contact sent a message to your number. Deduplicated β€” Meta redelivers webhooks, you do not get doubles.messageId, wamid, conversationId, contact, type, content
message.statusAn 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.failedAn 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.approvedMeta 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.rejectedMeta rejected a message template. `reason` carries Meta's rejection reason when it supplies one.templateId, metaTemplateId, name, language, category, status, reason
template.category_changedMeta 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.createdA 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.updatedA 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.deletedA 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.openedThe 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.closedThe 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.ratedA customer answered the satisfaction survey on a resolved conversation. `score` is 1-5. Fired once per conversation; later taps are ignored.conversationId, score
flow.completedA 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.missedA 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_responseA customer answered the cash-on-delivery confirmation for an order. `confirmed` false means they disowned it β€” hold the parcel.conversationId, contactId, reference, confirmed
order.eventA 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.completedA 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.createdA 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.recapturedA 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.submittedSomebody 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_changedA 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.assignedA 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.createdA follow-up task was created on a lead. `task` carries id, title, type, dueAt, priority, status and assignee.task, lead, createdAt
task.completedA task on a lead was marked done, with the outcome the person recorded.task, lead, outcome, completedAt
instagram.message.receivedA 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.receivedA 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.publishedA 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.paidA 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.issuedAn 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.updatedAn 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_recordedMoney 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.paidAn 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_voidedA 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.cancelledAn 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.acceptedA 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.declinedThe customer declined a quotation on its page. `reason` is what they wrote, when they wrote anything.quotation, reason, declinedAt
booking.createdA 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.rescheduledA 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.cancelledA 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.completedA team member marked a booking as completed: the visit happened.booking, completedAt
booking.no_showA team member marked a booking as a no-show: the customer did not come.booking, markedAt
booking.restoredA 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.receivedA 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.

Node.js
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.

BucketEndpointsBurstSustained
Reads/messages Β· /conversations Β· /contacts Β· /templates Β· /phone-numbers Β· GET /webhooks Β· Instagram reads Β· GET /payment-requests/{id}60060 / sec
Webhook adminPOST /webhooks6030 / sec
Webhook changesPATCH /webhooks/{id} Β· DELETE /webhooks/{id}601 / sec
Webhook secret rotationPOST /webhooks/{id}/rotate55 / hour

Every limit, the RateLimit-* headers and what to do on a 429: Rate limits.