Loading...
Developer documentation

Zapini Integrations API

Build your own integration consuming Zapini resources — Kanban boards, calendar appointments, chat conversations and support tickets — through a simple, predictable REST API.

Create and manage your API keys in the panel under Integrations → "Custom Integrations".

Open Custom Integrations
Base URL https://zapini.app/api/v1

Authentication

Every request is authenticated with an integration key. Keys are 51 characters long, start with the zpk_ prefix and are generated in the panel under Integrations → "Custom Integrations".

Your key is displayed only once, at creation time. Zapini stores only a SHA-256 hash of it, so it can never be recovered later — if you lose it, revoke the key and create a new one.

Send the key on every request as a Bearer token in the Authorization header. All requests and responses use JSON: set Content-Type: application/json when sending a body and Accept: application/json always.

Example request

curl https://zapini.app/api/v1/kanban/boards \
  -H "Authorization: Bearer zpk_your_51_char_key_here" \
  -H "Accept: application/json"

Scopes

Each key carries an explicit list of scopes chosen when it is created. A scope grants access to one resource, split into a read half and a write half.

Scope What it grants
kanban.read Read Kanban boards, columns, leads and lead activities.
kanban.write Create, update, move and delete Kanban boards, columns and leads.
calendar.read Read calendar appointments.
calendar.write Create, update, confirm, complete, cancel and delete appointments.
chat.read Read conversations and message history.
chat.write Send messages and media, archive conversations, manage tags and automation.
tickets.read Read support tickets.
tickets.write Create and update support tickets.
database.read Read collections and records of the internal database (Base Inteligente).
database.write Create, update and delete records in the internal database.
tms.read Read the transport module: fleet, freights, monitoring, occurrences, CAP/CAR and value lists.
tms.write Create, change and remove transport records.

GET and HEAD requests require the resource's .read scope; POST, PUT and DELETE requests require the corresponding .write scope.

Default deny: any endpoint outside these four resources is rejected with HTTP 403 and error code ENDPOINT_NOT_IN_SCOPE, no matter which scopes the key holds.

Security & limits

Custom integration keys are designed to be handed to third-party systems safely. The following controls apply to every key:

Rate limiting

Each key may perform up to 300 requests per minute. Above that the API answers HTTP 429 with a retry_after field (in seconds) — back off and retry.

IP allowlist (optional)

Restrict a key to a list of exact IP addresses or IPv4 CIDR ranges. Requests from any other address are rejected with 403 IP_NOT_ALLOWED.

Instance binding (optional)

A key can be bound to a single WhatsApp instance. Bound keys only see that instance's data and never need the instance_uuid parameter.

Revoke & rotate

Keys can be revoked or rotated at any time in the panel. Revocation is immediate — requests made with a revoked key receive 401.

HTTPS only

The API is served exclusively over HTTPS. Plain HTTP requests are not accepted.

Errors

Errors use conventional HTTP status codes plus a machine-readable error code in the JSON body.

Status Error code Description
401 INVALID_INTEGRATION_KEY The key is missing, malformed, revoked or unknown.
403 INSUFFICIENT_SCOPE The key lacks the scope this endpoint requires. The payload includes required_scope and granted_scopes to help you diagnose it.
403 ENDPOINT_NOT_IN_SCOPE The endpoint is outside the resources covered by the Integrations API (default deny).
403 IP_NOT_ALLOWED The request came from an IP address outside the key's allowlist.
429 RATE_LIMITED Too many requests. Wait retry_after seconds before retrying.
422 — Validation failed. Standard Laravel format: a message plus an errors object keyed by field name.
404 DATABASE_NOT_CONFIGURED The tenant has no Base Inteligente database configured, or the key is not bound to one.
404 COLLECTION_NOT_FOUND The collection does not exist or is outside this key's allowlist.
404 RECORD_NOT_FOUND No such record in that collection.
403 TMS_NOT_ENABLED The TMS module is not enabled for this tenant (crm_layout is not "tms").
422 DIRECTION_REQUIRED Listing finance entries requires direction=payable (CAP) or direction=receivable (CAR).

Example response — 403 INSUFFICIENT_SCOPE

{
  "success": false,
  "error": "INSUFFICIENT_SCOPE",
  "required_scope": "kanban.write",
  "granted_scopes": ["kanban.read", "chat.read"]
}

Kanban

Required scopes: kanban.read kanban.write

Manage sales boards, columns and leads — everything you see in the panel's Kanban module.

Boards are scoped per WhatsApp instance. Pass the instance_uuid query/body parameter on board-level calls — unless your key is bound to an instance, in which case it is implied.

The canonical prefix is /crm. The former /kanban still serves exactly the same routes, with the same payloads — no published integration has to change.

Method Endpoint Description
Boards
GET /crm/boards List the instance's boards.
POST /crm/boards Create a board.
GET /crm/boards/{uuid} Get a board with its columns.
PUT /crm/boards/{uuid} Update a board (name, color...).
DELETE /crm/boards/{uuid} Delete a board.
GET /crm/boards/{uuid}/stats Board totals: open, won, lost, values and the per-column breakdown.
POST /crm/boards/{uuid}/duplicate Duplicate the board with its columns (leads not copied).
Columns
GET /crm/boards/{uuid}/columns List the board's columns.
POST /crm/boards/{uuid}/columns Add a column to the board.
POST /crm/boards/{uuid}/columns/reorder Reorder the board's columns.
PUT /crm/columns/{uuid} Update a column.
DELETE /crm/columns/{uuid} Delete a column.
POST /crm/columns/{uuid}/clear Delete every lead in the column.
Leads
GET /crm/leads Search leads across the whole CRM (by phone, text, status, assignee or date range). Always paginated.
GET /crm/boards/{uuid}/leads List the leads on a board.
POST /crm/leads Create a lead in a column.
GET /crm/leads/{uuid} Get a lead.
PUT /crm/leads/{uuid} Update a lead.
DELETE /crm/leads/{uuid} Delete a lead.
POST /crm/leads/{uuid}/move Move a lead to another column/position.
POST /crm/leads/{uuid}/won Mark a lead as won.
POST /crm/leads/{uuid}/lost Mark a lead as lost.
POST /crm/leads/{uuid}/note Add a note to the lead's timeline.
GET /crm/leads/{uuid}/activities List the lead's activity history.
POST /crm/leads/{uuid}/assign Set the lead assignees.
POST /crm/leads/{uuid}/ignore Discard the lead and block the number in auto-detection.
POST /crm/leads/{uuid}/convert-to-ticket Open a ticket from the lead (idempotent).
POST /crm/boards/{uuid}/leads/import Import up to 500 leads in one call, with a per-row report.
POST /crm/leads/from-conversation Create a lead from a chat conversation.
POST /crm/leads/from-messages Create a lead from specific messages.
GET /crm/conversations/{uuid}/lead Get the lead linked to a conversation.
Lead attachments
GET /crm/leads/{uuid}/attachments List the lead attachments.
POST /crm/leads/{uuid}/attachments Upload an attachment to the lead (up to 25 MB, 10 per lead).
GET /crm/leads/{uuid}/attachments/{attachmentUuid} Download a lead attachment.
DELETE /crm/leads/{uuid}/attachments/{attachmentUuid} Delete a lead attachment.
AI on the lead
POST /crm/leads/{uuid}/ai-qualify Score the lead with AI and route it by the score.
POST /crm/leads/{uuid}/ai-suggest Suggest the next action for the lead with AI.
Automations
GET /crm/boards/{uuid}/automations List the board automations.
POST /crm/boards/{uuid}/automations Create an automation (trigger + action).
PUT /crm/automations/{uuid} Update an automation.
DELETE /crm/automations/{uuid} Delete an automation.
POST /crm/automations/{uuid}/test Run the automation against a real lead to check the configuration.
Auto-detection
GET /crm/boards/{uuid}/auto-detection Read the board lead auto-detection rules.
PUT /crm/boards/{uuid}/auto-detection Save the board auto-detection rules.
POST /crm/boards/{uuid}/auto-detection/preview Preview what would be imported, without creating anything.
POST /crm/boards/{uuid}/auto-detection/sync Create leads from the items picked in the preview.
GET /crm/boards/{uuid}/ignored-contacts List the contacts the board ignores.
POST /crm/boards/{uuid}/ignored-contacts Return an ignored contact to detection.
Custom fields
GET /crm/field-definitions Custom-field schema for building the form.
GET /crm/boards/{uuid}/fields Manage fields: includes disabled ones and how many records already hold a value in each.
POST /crm/boards/{uuid}/fields Create a custom field on the board.
POST /crm/boards/{uuid}/fields/reorder Reorder the board fields.
PUT /crm/fields/{id} Update a custom field (the internal name never changes).
DELETE /crm/fields/{id} Remove a field from the form (stored values are kept).
Configuration and helpers
GET /crm/config The tenant CRM configuration (standard, TMS or VC layout).
GET /crm/users Users who can be assigned to a lead.
GET /crm/tags Tenant tags, used in the detection filters.

Example request

curl -X POST https://zapini.app/api/v1/kanban/leads \
  -H "Authorization: Bearer zpk_your_51_char_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_uuid": "550e8400-e29b-41d4-a716-446655440000",
    "column_uuid": "c2b7e9d4-1f3a-4a5b-9c8d-7e6f5a4b3c2d",
    "name": "Jane Doe",
    "phone": "+5511999999999",
    "value": 1500
  }'

Calendar

Required scopes: calendar.read calendar.write

Create and manage the appointments shown in the panel's calendar, including status transitions.

Method Endpoint Description
GET /calendar/appointments List appointments (filter by period/status).
POST /calendar/appointments Create an appointment.
GET /calendar/appointments/{uuid} Get an appointment.
PUT /calendar/appointments/{uuid} Update an appointment.
DELETE /calendar/appointments/{uuid} Delete an appointment.
POST /calendar/appointments/{uuid}/confirm Mark the appointment as confirmed.
POST /calendar/appointments/{uuid}/complete Mark the appointment as completed.
POST /calendar/appointments/{uuid}/cancel Cancel the appointment.
POST /calendar/appointments/{uuid}/to-kanban Create a Kanban lead from the appointment.

Example request

curl -X POST https://zapini.app/api/v1/calendar/appointments \
  -H "Authorization: Bearer zpk_your_51_char_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Demo call",
    "start_at": "2026-07-10 14:00",
    "end_at": "2026-07-10 14:30",
    "contact_name": "Jane Doe",
    "contact_number": "+5511999999999"
  }'

Chat

Required scopes: chat.read chat.write

Read conversations and messages, and send text or media through your connected WhatsApp instance.

Method Endpoint Description
GET /chat/conversations List chat conversations.
GET /chat/conversations/{id} Get one conversation.
GET /chat/conversations/{id}/messages List a conversation's messages.
POST /chat/send Send a text message (body: conversation or recipient, plus message).
POST /chat/send-media Send a media message (image, audio, video, document).
POST /messages/send Queue an outgoing message (messages API).
GET /messages List messages (filterable).
GET /conversations List conversations (conversations resource).
POST /conversations/{id}/archive Archive / unarchive a conversation.
POST /conversations/{id}/tags Manage a conversation's tags.
POST /conversations/{id}/automation/pause Pause AI automation on the conversation.
POST /conversations/{id}/automation/resume Resume AI automation on the conversation.

Example request

curl -X POST https://zapini.app/api/v1/chat/send \
  -H "Authorization: Bearer zpk_your_51_char_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "+5511999999999",
    "message": "Hello from my integration!"
  }'

Tickets

Required scopes: tickets.read tickets.write

Open and track support tickets programmatically.

Method Endpoint Description
GET /tickets List tickets. Filters: status (open, in_progress, waiting, resolved, closed), priority (low, medium, high, urgent), category, search, per_page.
POST /tickets Create a ticket. subject is required; optional: description, priority, category, contact_name, contact_number, tags[], due_at.
GET /tickets/{uuid} Get a ticket by UUID.
PUT /tickets/{uuid} Update fields and/or transition the status.

Example request

curl -X POST https://zapini.app/api/v1/tickets \
  -H "Authorization: Bearer zpk_your_51_char_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Customer cannot log in",
    "description": "Password reset e-mail never arrives.",
    "priority": "low",
    "contact_name": "Jane Doe",
    "contact_number": "+5511999999999",
    "tags": ["login", "email"]
  }'

Example response — 201

{
  "success": true,
  "data": {
    "uuid": "9d3f2c1a-7b6e-4d5c-8a9b-0c1d2e3f4a5b",
    "code": "TK-8F3K2M1Q",
    "subject": "Customer cannot log in",
    "status": "open",
    "priority": "low",
    "category": null,
    "contact_name": "Jane Doe",
    "contact_number": "+5511999999999",
    "tags": ["login", "email"],
    "due_at": null,
    "created_at": "2026-07-02T12:00:00Z"
  }
}

Database (Base Inteligente)

Required scopes: database.read database.write

Base Inteligente is the tenant's internal database — properties, menu items, products, services, staff, whatever the vertical calls for. This API turns it into a headless CMS: your website reads and writes exactly the same records the client administers in the Zapini panel or the public portal. Nothing is duplicated, there is no sync to keep.

A key resolves to ONE Base Inteligente database of the tenant and, optionally, to a list of allowed collections. A collection outside that list answers 404 — the same as one that does not exist.

Method Endpoint Description
GET /database Database info: vertical (category) and the collections this key can see.
GET /database/collections Just the collection list, with display name and record count.
GET /database/collections/{collection} Field schema of a collection: type, required flag, enum options and references. Use it to build dynamic forms.
GET /database/collections/{collection}/records List records with search, filters, sorting, projection and pagination.
POST /database/collections/{collection}/records Create a record. The body is filtered against the schema — unknown fields are ignored.
GET /database/collections/{collection}/records/{id} Fetch one record by id.
PUT /database/collections/{collection}/records/{id} Update a record (PUT or PATCH). Send only the fields that change.
DELETE /database/collections/{collection}/records/{id} Delete a record.
POST /database/uploads Upload a file (multipart, field file) to the tenant gallery and get back the public URL to store in an image field.

Querying records

The listing endpoint accepts the parameters below. Field names are validated against the collection schema and operators come from a fixed allowlist — the query string never reaches the database raw.

Parameter Description
page Page number (default 1).
per_page Records per page (default 25, maximum 100).
search Free-text search across the text, e-mail, phone, document, URL and enum fields of the collection.
sort / order Sort field (from the schema, or created_at / updated_at) and direction asc | desc. Defaults to newest first.
fields Comma-separated list of fields to return. id always comes back.
filter[campo] Equality: filter[ativo]=true, filter[cidade]=Niterói.
filter[campo][op] With an operator: filter[preco][gte]=500000, filter[tipo][in]=casa,apartamento.
Accepted operators: eq ne gt gte lt lte in nin like starts ends exists

Example request

# Active listings up to R$ 800k, cheapest first
curl "https://zapini.app/api/v1/database/collections/imoveis/records?per_page=12&sort=preco&order=asc&filter[ativo]=true&filter[preco][lte]=800000" \
  -H "Authorization: Bearer zpk_your_51_char_key_here"

Example response — 200

{
  "success": true,
  "data": [
    {
      "id": "66b3f1c2a4d5e6f708192a3b",
      "titulo": "Apartamento 3 quartos — Icaraí",
      "preco": 780000,
      "quartos": 3,
      "foto_principal": "https://storage.zapini.app/gallery/8/image/....jpg",
      "ativo": true,
      "created_at": "2026-08-01 10:22:41"
    }
  ],
  "meta": { "current_page": 1, "per_page": 12, "total": 37, "last_page": 4 }
}

Creating a record

curl -X POST https://zapini.app/api/v1/database/collections/imoveis/records \
  -H "Authorization: Bearer zpk_your_51_char_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "titulo": "Cobertura duplex — Santa Rosa",
    "preco": 1250000,
    "quartos": 4,
    "ativo": true
  }'

Tip: call GET /database/collections/{collection} before writing. The schema returns each field's exact name, its type and the valid options of enum fields — that is what keeps writes from failing with 422.

TMS — Transport

Required scopes: tms.read tms.write

The whole transport module over HTTP: register vehicles and drivers, plan freights, track deliveries note by note, log incidents and keep payables and receivables in sync — the same data the operator sees in the panel.

These endpoints only answer for tenants running the TMS layout. Any other tenant gets 403 TMS_NOT_ENABLED, whatever scopes the key carries.

Fleet — one record per VEHICLE, keyed by plate, with its driver. The Grid (today's availability list) is a flag on that record, not a separate register.

Freights — the PLAN: origin, destination, cargo, vehicle. "code" (shipment number) is sequential per tenant and assigned by the server.

Monitoring — the EXECUTION: one row per invoice (NF). One freight has many rows; send "nr_embarque" and the server links them.

CAP/CAR — the money, one table split by "direction". Payables and receivables never mix in a single list.

Module & dashboards

Method Endpoint Description
GET /tms Module index: tenant, record counts and resource links.
GET /tms/overview Dashboard indicators — vehicle status, cargo status, cargo per customer, open loads per vehicle.
GET /tms/grid Fleet availability list. Filter with status[]=01..04; default hides "04 On a trip".
GET /tms/municipios?uf=SP IBGE municipalities for a state — feeds the city selector.

Fleet (vehicles & drivers)

Method Endpoint Description
GET /tms/fleet List vehicles. Filters: search, placa, classificacao, cadastro_status, uf, cidade, source, vehicle_type_id, body_type_id, in_grid, tem_mopp, per_page, sort, order.
GET /tms/fleet/options Valid IDs for the registration selects: vehicle types, body types, toll tags, trackers, statuses, classifications.
GET /tms/fleet/by-plate?placa=ABC1D23 Find a vehicle by plate. Hyphen, space and case are ignored.
POST /tms/fleet Register a vehicle. placa and nome_motorista are required; everything else (CNH, bank, trailers, dimensions) is optional.
GET /tms/fleet/{uuid} Full registration by UUID, including the complete dossier.
PUT /tms/fleet/{uuid} Partial update — only the keys you send are validated and written.
DELETE /tms/fleet/{uuid} Delete the registration for good. To take the vehicle off today's list only, use the Grid endpoint.
POST /tms/fleet/{uuid}/grid Put the vehicle on the Grid (availability list). Idempotent.
DELETE /tms/fleet/{uuid}/grid Take the vehicle off the Grid. The registration stays in the fleet.
PUT /tms/fleet/{uuid}/grid-status Override the Grid status (01 Empty, 02 Interested, 05 Negotiated, 04 On a trip; 03 To approve only comes from automatic). 05 keeps the load (freight_uuid) and schedules it (06, with the vehicle plate). null returns it to automatic. A plate cannot be 05 on two loads: if another scheduled/ongoing load has it, 409 PLATE_ALREADY_NEGOTIATED is returned.

Freights (planning)

Method Endpoint Description
GET /tms/freights List freights. Filters: status, operational_status, code, vehicle_plate, driver_phone, search, pickup_from, pickup_to, per_page, sort, order.
POST /tms/freights Create a freight. origin and destination are required; a known vehicle_plate fills in driver and vehicle from the fleet.
GET /tms/freights/{uuid} Get a freight by UUID, with notes and the meta object.
PUT /tms/freights/{uuid} Partial update. Sending operational_status also syncs the coarse status. Optional: `expected_operational_status` (code or list) — if the freight is not in it, nothing changes and 409 FREIGHT_STATUS_CHANGED is returned.
DELETE /tms/freights/{uuid} Soft-delete the freight.
PUT /tms/freights/{uuid}/status Move the coarse status: open, assigned, in_route, delivered, cancelled.
GET /tms/freights/{uuid}/compatible-drivers Drivers that fit this load, in three tiers: own fleet, third parties of the same reseller, distribution base.

Monitoring (per invoice)

Method Endpoint Description
GET /tms/monitoring List invoice rows. Filters: nr_embarque, freight_uuid, nf_entrega, placa, status_entrega_nf, status_embarque, cliente_operacao, uf, data_from, data_to, search.
POST /tms/monitoring Create an invoice row. Sending nr_embarque links it to the freight and copies plate, driver and customer when blank.
GET /tms/monitoring/{uuid} Get an invoice row by UUID.
PUT /tms/monitoring/{uuid} Partial update — this is where a tracker or WMS writes the delivery status and the proof of delivery.
DELETE /tms/monitoring/{uuid} Soft-delete the invoice row.

Occurrences (incidents)

Method Endpoint Description
GET /tms/occurrences List incidents. Filters: status, type, severity, freight_uuid, pending, search, from, to.
POST /tms/occurrences Log an incident: type, severity and title are required; freight_uuid is optional (standalone incidents exist).
GET /tms/occurrences/{uuid} Get an incident by UUID, with description and resolution.
PUT /tms/occurrences/{uuid} Partial update. Moving to resolved or closed stamps who closed it and when.
DELETE /tms/occurrences/{uuid} Soft-delete the incident.

Payables & receivables

Method Endpoint Description
GET /tms/cap Payables (CAP) — the same listing with direction already fixed.
GET /tms/car Receivables (CAR) — the same listing with direction already fixed.
GET /tms/finance List entries. direction is required. Filters: status, category, freight_uuid, counterparty, due_from, due_to, pending, search.
POST /tms/finance Create an entry. direction, category, description and amount_brl are required.
GET /tms/finance/{uuid} Get an entry by UUID, with reference and notes.
PUT /tms/finance/{uuid} Partial update. direction cannot change — cancel and re-issue instead.
DELETE /tms/finance/{uuid} Soft-delete the entry.
POST /tms/finance/{uuid}/pay Settle the entry. No body means paid in full; a smaller amount_paid_brl makes it partial.

Value lists (auxiliary registers)

Method Endpoint Description
GET /tms/option-lists All value lists at once. Narrow with ?lists=operational_statuses,finance_categories.
GET /tms/option-lists/{list} One list with its values and how many records use each one.
POST /tms/option-lists/{list} Add a value. stage applies to operational_statuses; directions applies to finance_categories.
PUT /tms/option-lists/{list}/{id} Rename, recolour, reorder or deactivate a value.
DELETE /tms/option-lists/{list}/{id} Remove a value. Values in use or owned by the system are refused — deactivate those instead.

Example request

curl -X POST https://zapini.app/api/v1/tms/freights \
  -H "Authorization: Bearer zpk_your_51_char_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "origin": "Santos/SP",
    "destination": "Betim/MG",
    "cargo_description": "Bobinas de aco",
    "cargo_weight_kg": 24000,
    "freight_value_brl": 7400.00,
    "vehicle_plate": "ABC1D23",
    "pickup_at": "2026-09-22T08:00:00-03:00",
    "delivery_eta": "2026-09-23T18:00:00-03:00",
    "operational_status": "06",
    "meta": { "nr_carga": "99812", "km": 780, "pedagio": 310.50 }
  }'

Example response — 201

{
  "success": true,
  "data": {
    "uuid": "9d3f2c1a-7b6e-4d5c-8a9b-0c1d2e3f4a5b",
    "code": "1482",
    "status": "assigned",
    "operational_status": "06",
    "origin": "Santos/SP",
    "destination": "Betim/MG",
    "driver_name": "Joao da Silva",
    "driver_phone": "+5513999999999",
    "vehicle_plate": "ABC1D23",
    "meta": { "nr_carga": "99812", "km": 780 },
    "created_at": "2026-09-17T12:00:00-03:00"
  }
}

The shipment number (code) is assigned by the server and never changes: Monitoring links invoices by it, so renumbering a shipment would orphan deliveries already posted.

operational_status, status_entrega_nf, status_embarque and category only accept values that exist in the tenant's lists. Read /tms/option-lists once to learn them, or create your own.

Every listing is paginated (per_page up to 200, meta carries current_page, last_page, per_page and total) and accepts sort and order.

Webhooks (outbound)

A custom integration may register a webhook URL to receive real-time events — for example message.received and message.sent — delivered as JSON POST requests.

Keys holding database.read also receive database.record.created, database.record.updated and database.record.deleted whenever a Base Inteligente record changes — the hook to revalidate your site cache. These events are tenant-level and carry no instance_uuid.

Keys with kanban.read also receive crm.lead.created, crm.lead.updated, crm.lead.moved, crm.lead.won, crm.lead.lost and crm.lead.deleted whenever a lead changes — through any path: panel, API, web form, import, auto-detection or automation. These are tenant events and carry no instance_uuid. Moves, wins and losses have their own event and are not repeated as crm.lead.updated. Deleting a BOARD removes its leads by database cascade, with no per-lead event: that case emits a single crm.board.deleted carrying the counts of what was removed.

Every delivery is signed with HMAC-SHA256. The X-Zapini-Signature header carries sha256= followed by the HMAC of the raw request body, computed with your webhook secret.

The webhook secret starts with zws_ and, like the API key, is shown only once when generated.

To verify a delivery, compute the HMAC over the raw request body (before any parsing) and compare it against the header using a timing-safe comparison.

Example delivery

POST https://your-app.example.com/webhooks/zapini
Content-Type: application/json
X-Zapini-Signature: sha256=5257a869e7ecebeda32affa27f4245b1...

{
  "event": "message.received",
  "timestamp": "2026-07-02T12:00:00Z",
  "data": {
    "conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
    "message": {
      "direction": "incoming",
      "sender_number": "+5511999999999",
      "content": "Hi! I would like a quote."
    }
  }
}

Verifying the signature (PHP)

$secret    = 'zws_your_webhook_secret';
$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_ZAPINI_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

// Timing-safe comparison
if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

Ready to build?

Create your first integration key and start calling the API in minutes.

Create an integration key