Webhooks
Receive signed events from Ricky, verify them and process each one once.
A webhook is a URL on your system that Ricky sends a POST to when an event happens in the organization. Ricky signs every delivery and retries it when it fails.
The key needs the webhooks:manage scope.
Create a webhook
curl https://getricky.ai/api/v1/webhooks \
-H "Authorization: Bearer $RICKY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.example.com/webhooks/ricky",
"event_types": ["message.received", "conversation.closed"]
}'{
"data": {
"id": "e2c4a6b8-0d1f-4e3a-9b5c-7d9f1a3c5e7b",
"url": "https://erp.example.com/webhooks/ricky",
"event_types": ["message.received", "conversation.closed"],
"status": "active",
"created_at": "2026-10-14T15:00:00.000Z",
"disabled_at": null,
"signing_secret": "whsec_…"
}
}Store signing_secret in your secret manager. Ricky shows it only in this response.
The URL must meet these conditions. Otherwise the response is 400 invalid_input with param: "url".
- It uses HTTPS on port 443.
- It uses a domain name, not an IP address.
- It carries no user name or password.
- The domain resolves to public addresses.
Ricky checks the URL again on every delivery and does not follow redirects.
Events
| Event | Happens when | data |
|---|---|---|
message.received | A contact sends a message. | conversation_id, message_id |
message.sent | The provider accepts a message from the organization. | conversation_id, message_id |
conversation.opened | A conversation is created or reopened. | conversation_id |
conversation.closed | A conversation is closed. | conversation_id |
conversation.assignment_changed | A conversation is assigned, reassigned or unassigned. | conversation_id |
message.sent fires for every outbound message, not only the ones sent through the API.
The event carries only IDs. For the content, read the resource through the API: Get a conversation or List messages.
The delivery
POST /webhooks/ricky HTTP/1.1
Host: erp.example.com
Content-Type: application/json
User-Agent: Ricky-Webhooks/1.0
X-Ricky-Delivery-Id: 5b8e0f3a-2c51-4b8e-9d0e-6f1a2b3c4d5e
X-Ricky-Timestamp: 1791992702
X-Ricky-Signature: v1=c4a06466fda49cd8401f485eb542ba41573b96f321758a2376acbffdd44c8ded
{"id":"5b8e0f3a-2c51-4b8e-9d0e-6f1a2b3c4d5e","type":"message.received","occurred_at":"2026-10-14T15:42:10.000Z","organization_id":"0d9c4b7e-1f2a-4c3d-8e5f-6a7b8c9d0e1f","data":{"conversation_id":"3f6d2a10-8b7c-4e1d-9a2f-5c4b3e2d1a09","message_id":"a7e5c3b1-9d8f-4a6e-b2c4-1d3f5e7a9b0c"}}| Header | Value |
|---|---|
X-Ricky-Delivery-Id | Delivery ID. It equals the body's id. |
X-Ricky-Timestamp | Unix time, in seconds, of this attempt. It changes on every retry. |
X-Ricky-Signature | v1= followed by the hex HMAC-SHA256. |
| Body field | Description |
|---|---|
id | Delivery ID. Use it to drop duplicates. |
type | Event type. |
occurred_at | When it happened, in ISO 8601 UTC. |
organization_id | The event's organization. |
data | conversation_id always; message_id on message.* events. |
Verify the signature
Verify every delivery before you use it:
- Read the raw body, as bytes, before parsing it as JSON. A re-serialized body does not match.
- Reject the delivery if
X-Ricky-Timestampis more than 5 minutes away from your clock. - Compute the HMAC-SHA256 of
<X-Ricky-Timestamp>.<raw body>. The key is the fullsigning_secret, including thewhsec_prefix. - Compare the hex result with what follows
v1=, in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRET = process.env.RICKY_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 5 * 60;
export function verifyRickySignature(rawBody, timestamp, signature, secret = SECRET) {
if (!timestamp || !signature?.startsWith('v1=')) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= TOLERANCE_SECONDS)) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(signature.slice(3), 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
const app = express();
// express.raw() keeps the body as bytes, exactly as Ricky signed it.
app.post('/webhooks/ricky', express.raw({ type: 'application/json' }), async (req, res) => {
const valid = verifyRickySignature(
req.body,
req.get('X-Ricky-Timestamp'),
req.get('X-Ricky-Signature'),
);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
await saveEventOnce(event); // your code: store it with event.id as a unique key
res.sendStatus(204);
});Test your verification
With these exact values, the HMAC must match. The 5-minute check fails at this time, so test it separately or freeze the clock in your test.
| Input | Value |
|---|---|
| Secret | whsec_test_5YtV0kq9mUz2Rr8Lw3Nf1Hc6Pb4Xa7Ds0Ge2Jo9Ki8 |
| Timestamp | 1791992702 |
| Body | The body of the example in The delivery, with no spaces and no trailing newline. |
| Signature | v1=c4a06466fda49cd8401f485eb542ba41573b96f321758a2376acbffdd44c8ded |
Respond
- Answer with any
2xxstatus within 10 seconds. Ricky closes the connection at 10 seconds and counts it as a failure. - Store the event and respond. Do the heavy work afterwards, outside the request.
- A redirect, a non-
2xxstatus, a network error or a response larger than 64 KiB counts as a failure.
Cadence and retries
Ricky sends deliveries in passes every 15 minutes. A new event or a retry waits for the next pass. Webhooks are not a real-time channel: if you need data sooner, read it through the API.
When an attempt fails, Ricky waits before the next one: at least 30 seconds after the first failure, doubling each time, up to 1 hour. The attempt happens in the first pass after that wait. After 10 failed attempts, the delivery becomes failed.
Duplicates and order
- The same delivery can arrive more than once, for example when your server answered after the 10 seconds. Drop duplicates by
id. - Deliveries can arrive in a different order than the events. Order them by
occurred_atand read the current state through the API before acting.
Delivery log
GET /webhooks/{webhook_id}/deliveries returns the 100 most recent deliveries of a webhook.
{
"data": [
{
"id": "5b8e0f3a-2c51-4b8e-9d0e-6f1a2b3c4d5e",
"event_type": "message.received",
"status": "failed",
"attempts": 10,
"last_response_status": 503,
"last_error": "HTTP 503",
"created_at": "2026-10-14T15:42:10.000Z",
"next_attempt_at": "2026-10-15T00:12:41.000Z",
"delivered_at": null
}
],
"next_cursor": null
}status | Meaning |
|---|---|
pending | Waiting for its next attempt, at next_attempt_at or later. |
processing | Ricky is sending it. |
delivered | Your server answered 2xx. |
failed | It used up its 10 attempts. |
Manual retry
After fixing your server, queue a failed delivery again:
curl -X POST https://getricky.ai/api/v1/webhooks/e2c4a6b8-0d1f-4e3a-9b5c-7d9f1a3c5e7b/deliveries/5b8e0f3a-2c51-4b8e-9d0e-6f1a2b3c4d5e/retry \
-H "Authorization: Bearer $RICKY_API_KEY"The delivery goes back to pending with 10 fresh attempts and leaves in the next pass. Only failed deliveries of an active webhook can be retried; any other gets 404 not_found.
Disable a webhook
DELETE /webhooks/{webhook_id} stops new and pending deliveries. A disabled webhook cannot be enabled again: create another one. Disabling it twice answers 404 not_found.
To change the secret, create a new webhook, deploy its secret and disable the old one. While both are active, each event arrives twice, once per webhook, with different id values.