Signing and verification
Every webhook request Chatmo sends is signed with HMAC-SHA256 by default β you don't have to configure anything for this. On top of that, you can optionally add your own custom headers (a bearer token, an API key) if your receiving system expects its own auth scheme too. This hybrid approach is the same pattern used by Stripe, GitHub, and Shopify.
Headers sent with every request
| Header | Description |
|---|---|
X-Chatmo-Event | The event type, e.g. message.received. Matches the event field in the body. |
X-Chatmo-Signature | sha256=<hex-encoded HMAC> β see verification below. |
X-Chatmo-Timestamp | ISO 8601 timestamp of when the request was sent. Matches created_at in the body. |
X-Chatmo-Delivery-Id | A unique id for this delivery attempt-group β use it to deduplicate retries (see Create a Webhook Endpoint). |
| Your custom headers | Any headers you configured when creating the webhook, sent as-is. |
Verifying the signature
The signature is an HMAC-SHA256 of the raw request body, using your webhook's signing secret as the key, encoded as a hex string and prefixed with sha256=.
- Read the raw, unparsed request body as bytes.
- Compute
HMAC-SHA256(secret, raw_body). - Hex-encode the result and compare it β using a constant-time comparison β against the value in
X-Chatmo-Signature(after stripping thesha256=prefix).
:::warning Use the raw body Signing is computed over the exact bytes Chatmo sent. If your framework parses the JSON and you re-serialize it before comparing, whitespace or key-ordering differences will make every signature check fail. Always verify against the untouched raw body β see Create a Webhook Endpoint for framework-specific notes. :::
See Sample Code for a working verification snippet in cURL, Node.js, Python, PHP, and Go.
Where to find your signing secret
The secret is generated automatically when you create a webhook and shown to you once, immediately after creation β see Create a Webhook. Chatmo never displays it again after that; only a masked preview (e.g. whseβ¦9F2a) is shown in the dashboard from then on.
If you lose it, use Regenerate on the webhook's Edit panel. This immediately invalidates the old secret β update your verification code before regenerating, or you'll reject valid requests until you do.
Adding your own auth on top
If your endpoint already requires its own authentication β a static API key, a bearer token β add it as a custom header when creating or editing the webhook (the Advanced: Custom Headers section). Chatmo sends it on every request exactly as configured, alongside its own signature headers. This is optional; the HMAC signature alone is sufficient to verify a request genuinely came from Chatmo and wasn't tampered with in transit.
Best practices
- Store the signing secret the same way you'd store any other credential β an environment variable or secrets manager, never in source control.
- Always verify the signature before doing anything with the payload, including before enqueuing it for processing.
- Reject requests with a missing or malformed
X-Chatmo-Signatureheader outright (401). - Rotate the secret (via Regenerate) if you suspect it's been exposed.
Retries and delivery guarantees
Chatmo delivers webhooks at least once. A delivery is retried automatically when your endpoint doesn't confirm it was received β which means your endpoint should be able to safely handle receiving the same event more than once (see idempotency).
Retry schedule
| Attempt | Wait before this attempt |
|---|---|
| 1 | β (immediate) |
| 2 | ~3 seconds |
| 3 | ~9 seconds |
| 4 | ~27 seconds |
| 5 | ~81 seconds |
| 6 (final) | up to 10 minutes |
Each attempt times out after 10 seconds if your endpoint doesn't respond. Wait times include a small amount of random jitter so retries from many failing endpoints don't all land on your infrastructure at the same instant.
What counts as a failure
| Your endpoint | Chatmo's response |
|---|---|
Responds 2xx within 10s | Delivered β no retry |
Responds 401, 403, or 410 | Not retried β see non-retryable responses |
| Responds any other status, times out, or the connection fails | Retried on the schedule above |
After the final attempt fails, the delivery is marked FAILED in the delivery log β you can inspect it or resend it manually once your endpoint is fixed.
Auto-disable after repeated failures
If a webhook accumulates 20 consecutive fully-failed deliveries (every attempt across all 6 retries exhausted), Chatmo automatically:
- Deactivates the webhook (
is_active: false). - Records the reason on the webhook.
- Sends an in-app, desktop, and email notification to the organization owner.
This mirrors how Stripe and similar providers handle dead endpoints β it stops a permanently broken URL from being retried forever. A single successful delivery resets the failure counter to zero, so an endpoint that's just occasionally flaky is never at risk of this.
To bring a disabled webhook back, fix the underlying issue and switch it to Active again from the Edit panel β this also resets the failure counter.
Ordering
Deliveries to the same webhook endpoint are not guaranteed to arrive in the exact order the underlying events happened, especially across retries. If your integration depends on strict ordering, use the event's created_at timestamp (or the resource's own timestamps in data) to reorder on your end rather than relying on arrival order.
What isn't retried
Deliveries you trigger yourself β a test event or a manual resend β go through the same retry schedule as a real event. There's no separate "no retry" mode for these; if you want to see the raw failure without waiting for retries to finish, check the delivery log entry while attempts are still in progress (status: PENDING).
