Ricky logorickyDocs

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"]
  }'
201 Created
{
  "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

EventHappens whendata
message.receivedA contact sends a message.conversation_id, message_id
message.sentThe provider accepts a message from the organization.conversation_id, message_id
conversation.openedA conversation is created or reopened.conversation_id
conversation.closedA conversation is closed.conversation_id
conversation.assignment_changedA 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"}}
HeaderValue
X-Ricky-Delivery-IdDelivery ID. It equals the body's id.
X-Ricky-TimestampUnix time, in seconds, of this attempt. It changes on every retry.
X-Ricky-Signaturev1= followed by the hex HMAC-SHA256.
Body fieldDescription
idDelivery ID. Use it to drop duplicates.
typeEvent type.
occurred_atWhen it happened, in ISO 8601 UTC.
organization_idThe event's organization.
dataconversation_id always; message_id on message.* events.

Verify the signature

Verify every delivery before you use it:

  1. Read the raw body, as bytes, before parsing it as JSON. A re-serialized body does not match.
  2. Reject the delivery if X-Ricky-Timestamp is more than 5 minutes away from your clock.
  3. Compute the HMAC-SHA256 of <X-Ricky-Timestamp>.<raw body>. The key is the full signing_secret, including the whsec_ prefix.
  4. 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.

InputValue
Secretwhsec_test_5YtV0kq9mUz2Rr8Lw3Nf1Hc6Pb4Xa7Ds0Ge2Jo9Ki8
Timestamp1791992702
BodyThe body of the example in The delivery, with no spaces and no trailing newline.
Signaturev1=c4a06466fda49cd8401f485eb542ba41573b96f321758a2376acbffdd44c8ded

Respond

  • Answer with any 2xx status 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-2xx status, 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_at and 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.

200 OK
{
  "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
}
statusMeaning
pendingWaiting for its next attempt, at next_attempt_at or later.
processingRicky is sending it.
deliveredYour server answered 2xx.
failedIt 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.

On this page