Skip to Content
Webhooks

Webhooks

Cloove webhooks send events to your server when calls, messages, orders, products, payments, wallet activity, contacts, or hotel operations change.

This page covers outbound developer webhooks. Provider callbacks sent into Cloove by payment, telephony, or messaging providers are separate internal integrations.

Delivery model

  1. A Cloove operation produces an event.
  2. Cloove creates one delivery for each active endpoint subscribed to that event.
  3. A background worker signs the persisted JSON payload and sends an HTTPS POST.
  4. A 2xx response marks that endpoint delivery successful.
  5. Timeouts, network errors, redirects, and non-2xx responses are retried.

Webhook delivery is at least once. The same event can reach your endpoint more than once, and separate events can arrive out of order.

Configure an endpoint

Webhook configuration uses the authenticated Developer Portal API, not a public API key. Requests require a dashboard JWT, the MANAGE_DEVELOPER_KEYS permission, and an active business context.

Base path: /api/developer

Create or select a developer app

Webhook endpoints and signing secrets belong to a developer app and environment. Use a test app for test integrations and a live app for production activity.

Register an endpoint

POST /api/developer/webhook-endpoints Content-Type: application/json
{ "name": "Production events", "developer_app_id": "b7a4e408-32e5-4fae-92ac-fbf63ca40934", "url": "https://example.com/webhooks/cloove", "environment": "live", "events": [ "order.created", "order.updated", "payment.received" ] }

Retrieve the signing secret

POST /api/developer/webhook-settings/live/view-secret Content-Type: application/json
{ "developer_app_id": "b7a4e408-32e5-4fae-92ac-fbf63ca40934" }

Viewing or rotating a signing secret requires recent password verification. Store the returned whsec_... value in your server-side secret manager.

Verify and acknowledge deliveries

Verify the signature against the raw body, persist the event ID, enqueue your own work, and return a 2xx response before the 10-second timeout.

Endpoint rules

RuleRequirement
SchemeHTTPS only
Public reachabilityLocalhost and private-network IP targets are rejected
CredentialsURLs cannot contain embedded usernames or passwords
FragmentURL fragments are removed before storage
EventsAt least one supported event is required
LimitUp to 8 active endpoints per developer app and environment

Endpoint resource

{ "id": "b27a432e-c8dd-46a8-9e9f-75d740f5d716", "businessId": "6cfef582-cbd0-4755-9c65-53dcaea00e64", "developerAppId": "b7a4e408-32e5-4fae-92ac-fbf63ca40934", "name": "Production events", "url": "https://example.com/webhooks/cloove", "environment": "live", "events": ["order.created", "order.updated", "payment.received"], "status": "active", "lastDeliveryStatus": "delivered", "failureCount": 0, "lastDeliveredAt": "2026-07-14T10:02:03.000Z", "disabledAt": null, "createdAt": "2026-07-01T08:00:00.000Z", "updatedAt": "2026-07-14T10:02:03.000Z" }

status is either active or disabled. Only active endpoints receive new events.

Manage endpoints

MethodEndpointPurpose
GET/api/developer/webhook-eventsGet the canonical event catalog
GET/api/developer/webhook-endpointsList endpoints
POST/api/developer/webhook-endpointsCreate an endpoint
PATCH/api/developer/webhook-endpoints/:idUpdate name, URL, or subscriptions
POST/api/developer/webhook-endpoints/:id/disableStop delivery
POST/api/developer/webhook-endpoints/:id/enableResume delivery and reset failures
POST/api/developer/webhook-endpoints/:id/testQueue a signed test delivery
GET/api/developer/webhook-settingsList environment signing settings
POST/api/developer/webhook-settings/:environment/view-secretRetrieve a signing secret
POST/api/developer/webhook-settings/:environment/rotate-secretReplace a signing secret
GET/api/developer/webhook-deliveriesList the latest 100 deliveries
POST/api/developer/webhook-deliveries/:id/resendQueue the stored event again

Use developer_app_id as a query parameter on endpoint, setting, and delivery list requests. Send it in the JSON body when viewing or rotating a secret.

The endpoint’s developer app and environment are immutable. Create another endpoint if either association must change.

Send a test delivery

POST /api/developer/webhook-endpoints/b27a432e-c8dd-46a8-9e9f-75d740f5d716/test

This queues a signed webhook.test event directly to the selected active endpoint. The endpoint does not need to subscribe to webhook.test. The response is 202 Accepted and contains the delivery record, which can be monitored through the deliveries API.

{ "type": "webhook.test", "apiVersion": "v1", "data": { "endpointId": "b27a432e-c8dd-46a8-9e9f-75d740f5d716", "message": "Cloove webhook test delivery" } }

Request format

Every delivery is an HTTPS POST with Content-Type: application/json.

Headers

HeaderDescription
Cloove-Signaturet=<unix-seconds>,v1=<hmac-sha256-hex>
Cloove-Webhook-IdStable event ID used across retries and manual resends
Cloove-Webhook-EventEvent type such as order.created
Content-Typeapplication/json

Header names are case-insensitive as required by HTTP.

Envelope

{ "id": "5d8848c5-f009-4b5d-9bb2-4e733f32cb6f", "type": "vox.call.completed", "apiVersion": "v1", "businessId": "6cfef582-cbd0-4755-9c65-53dcaea00e64", "created": "2026-07-14T10:02:02.000Z", "environment": "live", "data": { "callId": "96f6450d-f369-4c27-8688-d8190e60d7f4", "status": "completed", "direction": "outbound", "durationSeconds": 122, "recordingUrl": null } }
FieldTypeDescription
iduuidEvent identifier and idempotency key
typestringEvent type from the catalog
apiVersionstringWebhook contract version, currently v1
businessIduuidBusiness that owns the event
createdISO timestampTime Cloove created the webhook event
environmentenumtest or live
dataobjectEvent-specific payload

The id value matches Cloove-Webhook-Id. All subscribed endpoints receive the same event ID for the same emitted event. Each endpoint still has its own delivery record and retry lifecycle.

Verify signatures

Never process a delivery until its signature and timestamp have been verified.

Cloove signs these exact bytes:

HMAC-SHA256(signing_secret, unix_timestamp + "." + raw_request_body)

The result is encoded as lowercase hexadecimal and placed in v1.

Verify the raw request body exactly as received. Parsing and re-serializing JSON can change whitespace or key ordering and invalidate the signature.

Replay tolerance

Reject signatures whose t value is too far from the current time. Five minutes is a reasonable default. Timestamp validation limits captured-request replay, while event-ID deduplication protects normal retries and manual resends.

const crypto = require('node:crypto') function verifyClooveWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) { if (!Buffer.isBuffer(rawBody) || !signatureHeader || !secret) return false const values = new Map() for (const part of signatureHeader.split(',')) { const separator = part.indexOf('=') if (separator > 0) values.set(part.slice(0, separator), part.slice(separator + 1)) } const timestamp = Number(values.get('t')) const providedHex = values.get('v1') if (!Number.isInteger(timestamp) || !/^[a-f0-9]{64}$/i.test(providedHex || '')) { return false } const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp) if (age > toleranceSeconds) return false const expectedHex = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest('hex') return crypto.timingSafeEqual( Buffer.from(expectedHex, 'hex'), Buffer.from(providedHex, 'hex') ) }

Framework raw-body handling

In Express, use express.raw({ type: 'application/json' }) on the webhook route before global JSON parsing. In Next.js route handlers, read await request.arrayBuffer() before calling JSON.parse. In Python frameworks, use the request body’s byte-oriented API.

Receiver pattern

Your handler should perform only the work required to authenticate and durably accept the event.

app.post('/webhooks/cloove', express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.get('Cloove-Signature') if (!verifyClooveWebhook(req.body, signature, process.env.CLOOVE_WEBHOOK_SECRET)) { return res.status(400).send('Invalid signature') } const event = JSON.parse(req.body.toString('utf8')) const inserted = await events.insertIfAbsent(event.id, event) if (inserted) await jobs.enqueue({ eventId: event.id }) return res.status(204).end() })

Use a unique constraint on the event ID. Do not rely on an in-memory set because it is lost when a process restarts and does not coordinate multiple application instances.

Event catalog

The canonical catalog is available from:

GET /api/developer/webhook-events

Receivers should ignore unknown fields and safely log unknown event types. New fields can be added to existing payloads without changing apiVersion.

Vox calls and agents

EventFires when
vox.call.startedA call enters in_progress
vox.call.completedA call completes
vox.call.failedA call fails or is missed
vox.recording.readyA recording URL first becomes available
vox.agent.createdA voice agent is created or duplicated
vox.agent.updatedA voice agent configuration is updated
vox.agent.deletedA voice agent is deleted

Call event data:

{ "callId": "96f6450d-f369-4c27-8688-d8190e60d7f4", "status": "completed", "direction": "outbound", "durationSeconds": 122, "recordingUrl": null }

Agent event data:

{ "id": "d94d1454-ff9e-47da-aac8-d1c509f7794b", "name": "Sales concierge", "status": "active", "storeId": null, "language": "en-NG", "tone": "professional", "isDefault": true, "updatedAt": "2026-07-14T10:00:00.000Z" }

Messaging

EventFires when
messaging.message.receivedAn inbound WhatsApp message is persisted
messaging.message.sentAn outbound WhatsApp message is persisted
messaging.message.delivery_updatedMeta reports sent, delivered, read, or failed
messaging.conversation.assignedA business inbox conversation is assigned

Inbound message data:

{ "conversationId": "94d8f967-32bb-4740-a6ea-71da41c401b8", "messageId": "53c6e4aa-d3d2-45a3-87ab-af8945d0486d", "customerPhone": "+2348012345678", "messageType": "text", "text": "Is my order ready?", "receivedAt": "2026-07-14T10:00:00.000Z" }

Delivery update data:

{ "messageId": "53c6e4aa-d3d2-45a3-87ab-af8945d0486d", "conversationId": "94d8f967-32bb-4740-a6ea-71da41c401b8", "deliveryStatus": "delivered", "metaMessageId": "wamid.HBg..." }

Orders, payments, contacts, inventory, and wallet

EventFires when
order.createdAn order is recorded
order.updatedOrder status, payment, customer, or scheduling changes
order.cancelledAn order is cancelled or deleted
payment.receivedA payment is recorded against a business entity
contact.createdA customer or contact is created
contact.updatedA customer or contact is updated
inventory.low_stockInventory crosses its low-stock threshold
inventory.out_of_stockInventory reaches zero
wallet.withdrawal.requestedA withdrawal is initiated
wallet.withdrawal.completedA withdrawal settles
wallet.withdrawal.failedA withdrawal fails
wallet.depositFunds are credited to the wallet

These events originate from Cloove’s business event ledger and share this data shape:

{ "businessId": "6cfef582-cbd0-4755-9c65-53dcaea00e64", "eventType": "ORDER_CREATED", "entityType": "sale", "entityId": "5037c85a-44c6-4c3a-bd7d-3e21e73915e1", "storeId": "837ceae4-43c2-4bbb-830c-1099d36b9d05", "metadata": { "shortCode": "482193", "totalAmount": 7000 }, "occurredAt": "2026-07-14T10:00:00.000Z" }

metadata varies by operation and can gain fields over time. Use entityId to retrieve the latest resource when a complete current representation is required. The webhook is a change notification, not a replacement for resource retrieval.

For a payment.received event created through a payment link, data also includes the durable payment-link and customer mapping:

{ "businessId": "6cfef582-cbd0-4755-9c65-53dcaea00e64", "eventType": "PAYMENT_RECEIVED", "entityType": "sale", "entityId": "5037c85a-44c6-4c3a-bd7d-3e21e73915e1", "metadata": { "amount": 50000, "paymentMethod": "VIRTUAL_ACCOUNT", "paymentLinkId": "79b70c64-81f4-4b7c-a72f-b39dd253ea42", "customerId": "5c2ec758-b919-46b9-82f7-5fcbc2636b66" }, "paymentLink": { "id": "79b70c64-81f4-4b7c-a72f-b39dd253ea42", "reference": "CLV-PL-0001", "targetType": "wallet", "targetId": null, "amount": 50000, "currency": "NGN" }, "customer": { "id": "5c2ec758-b919-46b9-82f7-5fcbc2636b66", "name": "Ada Lovelace", "email": "ada@example.com", "phoneNumber": "2348012345678" }, "occurredAt": "2026-07-14T10:00:00.000Z" }

Use customer.id as the Cloove customer identifier. Email and phone number are included so you can reconcile the event with an external customer record. paymentLink.reference identifies the exact link that collected the payment. Payments that did not originate from a payment link do not include paymentLink or the payment-link customer block.

There is no order.refunded event in the current public catalog. A database-only status change is not treated as a refund. Refund events will be introduced with a money-moving refund workflow.

Products

EventFires when
product.createdA product is created
product.updatedCore product configuration is updated
product.deletedA product is soft-deleted
{ "id": "7a59174f-3207-42a6-8fab-5b8d8b2929fb", "name": "Jollof Rice", "type": "simple", "basePrice": 3500, "categoryId": "bf0824e4-f6b8-4a47-9ef9-c6bc5dd8c6f4", "unit": "PLATE", "isActive": true, "isExtraOnly": false, "updatedAt": "2026-07-14T10:00:00.000Z" }

Stock threshold events remain separate from product configuration events. Subscribe to both product and inventory events when maintaining an external catalog with availability.

Hotel

EventFires when
hotel.reservation.createdA reservation is created
hotel.reservation.checked_inA guest is checked in
hotel.reservation.checked_outA guest is checked out
hotel.reservation.cancelledA reservation is cancelled
hotel.service_request.createdA guest service request is created

Reservation events contain the same reservation representation described in the Hotel API.

Service request data:

{ "id": "b601cf35-05ef-4574-859b-6fe81807ae6c", "category": "housekeeping", "status": "new", "guestId": "80be51aa-da85-4081-9b7a-9f4f72d0433f", "reservationId": "20b74d5c-7635-4671-9b0d-3d112a875690", "roomId": "b0825fc0-e38c-4813-b59d-ef35d96cf9b1", "source": "dashboard" }

Delivery attempts and retries

A delivery succeeds only when the endpoint returns a status from 200 through 299 within 10 seconds. Response bodies are ignored.

BehaviorValue
Total automatic attempts5
BackoffExponential, beginning at approximately 5 seconds
Request timeout10 seconds per attempt
Endpoint auto-disable15 consecutive failed attempts
Success effectResets the endpoint failure count to zero

Redirects are not success responses. Configure the final HTTPS URL instead of depending on a 301, 302, 307, or 308 response.

Each retry uses the same event ID and persisted payload. The signature timestamp and HMAC are generated again for each attempt.

Inspect deliveries

GET /api/developer/webhook-deliveries?developer_app_id=b7a4e408-32e5-4fae-92ac-fbf63ca40934

The API returns the latest 100 deliveries in reverse chronological order.

{ "id": "02f5f289-e5d4-42e9-9833-1a49a840e340", "businessId": "6cfef582-cbd0-4755-9c65-53dcaea00e64", "developerAppId": "b7a4e408-32e5-4fae-92ac-fbf63ca40934", "webhookEndpointId": "b27a432e-c8dd-46a8-9e9f-75d740f5d716", "environment": "live", "eventId": "5d8848c5-f009-4b5d-9bb2-4e733f32cb6f", "eventType": "order.created", "status": "failed", "url": "https://example.com/webhooks/cloove", "attempts": 2, "responseStatus": 500, "latencyMs": 184, "errorMessage": "http_500", "payloadPreview": { "preview": "{\"id\":\"5d8848c5-f009-4b5d-9bb2-4e733f32cb6f\",..." }, "nextAttemptAt": "2026-07-14T10:02:10.000Z", "deliveredAt": null, "createdAt": "2026-07-14T10:02:02.000Z", "updatedAt": "2026-07-14T10:02:05.000Z" }

Delivery statuses

StatusMeaning
pendingCreated and waiting for its first attempt or manual resend
deliveredEndpoint returned 2xx
failedLatest attempt timed out, failed to connect, or returned non-2xx

failed can be temporary while automatic retries remain. Use attempts, nextAttemptAt, and the endpoint’s failureCount when diagnosing repeated failures.

Manual resend

POST /api/developer/webhook-deliveries/02f5f289-e5d4-42e9-9833-1a49a840e340/resend

Manual resend resets the delivery result and queues the persisted payload again. The event ID and JSON payload remain the same. The request receives a new signature timestamp.

The destination endpoint must be active. Enabling an endpoint does not automatically resend its failed deliveries; request resends for the deliveries you still need.

Disable and re-enable behavior

An endpoint is automatically disabled after 15 consecutive failed attempts. It stops receiving new events and queued attempts stop when the worker observes the disabled state.

After fixing the receiver:

  1. Enable the endpoint with POST /webhook-endpoints/:id/enable.
  2. Confirm its URL and subscriptions.
  3. Resend relevant failed deliveries.
  4. Monitor for a successful delivery, which keeps the failure count at zero.

Rotate a signing secret

POST /api/developer/webhook-settings/live/rotate-secret Content-Type: application/json
{ "developer_app_id": "b7a4e408-32e5-4fae-92ac-fbf63ca40934" }

Rotation replaces the secret for that developer app and environment immediately. New attempts, including retries of older events, are signed with the new secret. There is no automatic overlap period for the old secret.

Store the returned secret securely and update every receiver for the app. Never expose it in browser JavaScript, mobile application bundles, logs, or source control.

Operational checklist

  • Verify the signature against raw bytes before parsing.
  • Enforce a timestamp tolerance.
  • Deduplicate with a database unique constraint on id.
  • Persist or enqueue before returning 2xx.
  • Keep the handler below the 10-second timeout.
  • Treat events as unordered.
  • Retrieve the current API resource when exact latest state matters.
  • Ignore unknown fields and handle unknown event types safely.
  • Alert on repeated failures and disabled endpoints.
  • Keep test and live secrets separate.
  • Rotate a secret immediately if it is exposed.

Troubleshooting

SymptomCheck
Signature mismatchRaw body was changed, wrong app or environment secret, stale timestamp
Repeated duplicate processingEvent ID is not stored with a unique constraint
http_301 or http_302Register the final callback URL
TimeoutAcknowledge before running business logic
No deliveriesEndpoint status, environment, developer app, and event subscriptions
Endpoint disabledFix the receiver, enable it, then resend required failures
Old secret stopped workingSecret rotation takes effect immediately

See the Developer Portal API for API key and app management, and API conventions for compatibility rules.

Last updated on