Rutba CRM

Webhooks

Receive signed events from Rutba CRM when leads arrive, orders are confirmed, quotes are accepted and deals are won.

A webhook tells your system the moment something happens in your workspace, so you don't have to keep asking the API. Each event is sent as a signed JSON POST to an HTTPS address you choose. This page is for the developer who builds the receiving end. Setting webhooks up on the screen is also covered in Outgoing webhooks.

Before you start

  • Webhooks are available when your plan includes API access. Without it, no events are queued and none are sent.
  • Only owners and administrators can add, pause, resume and remove webhooks, on the screen or through the API.
  • Your receiving address must use https on the standard port 443, with no user name, password or # fragment, and be at most 500 characters. Its host name must resolve to a public IPv4 address. Internal, private and reserved addresses are never contacted.
  • A company can have up to 5 webhook addresses, including paused ones. Removed addresses do not count.

Events

Event Label on the screen Sent when subject amount_minor
lead_created A new lead arrives A new contact is created by a website form, a connected store or a first message on a connected channel. Not for contacts added by hand, imported or created through the API contact:CONTACT_ID null
order_confirmed A store order is confirmed A store order first reaches paid, processing, completed or delivered. Orders brought in by an import of past orders are not sent order:ORDER_ID The order total
quote_accepted A quote is accepted A quote is accepted, by your team or by the customer through the quote link quote:QUOTE_ID The quote total
deal_won A deal is won A deal is marked won deal:DEAL_ID The deal amount, or null

Each event is sent at most once per address for the same record. For example, a deal that is reopened and won again within 30 days is not sent a second time.

Subscribe a webhook

On the screen

  1. Open Contacts, then Tools, then API keys.
  2. Select Webhooks.
  3. Under Add webhook, type the HTTPS address.
  4. Under Send when, tick one or more events.
  5. Select Add webhook.
  6. Copy the signing secret from the box that says Copy this signing secret now. It will not be shown again.

Through the API

Automation tools can subscribe themselves with a Read and write key whose creator is an owner or administrator:

curl -X POST https://rutbacrm.com/api/v1/hooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/incoming", "events": ["lead_created", "deal_won"]}'

The response is 201 with the webhook's ID and its signing secret, shown this once:

{
  "id": "HOOK_ID",
  "secret": "YOUR_WEBHOOK_SECRET"
}

Unknown event names are dropped. If none of the events is known, the response is 422 with the error under events. A bad address answers 422 under url, as does a sixth address.

To remove it, send DELETE with the ID. Content-Type: application/json is required here too:

curl -X DELETE https://rutbacrm.com/api/v1/hooks/HOOK_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json"
{
  "status": "removed"
}

The address is removed at once. It disappears from the Webhooks screen, is never sent to again and no longer counts towards the 5 addresses. Events still waiting for it are marked Skipped. A removed address cannot be resumed, and sending DELETE for it again answers 404. To use the same URL again, subscribe it anew: it gets a new ID and a new secret. Owners and administrators can also click Remove on the Webhooks screen, with the same effect.

The request

Each event is a POST with a JSON body and these headers:

Header Value
Content-Type application/json
X-Webhook-Event The event name, such as deal_won
X-Webhook-Delivery The delivery ID. It is the same on every retry of this event
X-Webhook-Signature t=TIMESTAMP,v1=SIGNATURE
{
  "id": "DELIVERY_ID",
  "event": "order_confirmed",
  "occurred_at": "2026-11-08T09:30:00Z",
  "data": {
    "contact": {
      "id": "CONTACT_ID",
      "name": "Maryam Saleh",
      "email": "maryam@example.com",
      "phone": "+966500000001"
    },
    "subject": "order:ORDER_ID",
    "amount_minor": 125050,
    "currency": "SAR"
  }
}
Field Meaning
id The delivery ID, the same as X-Webhook-Delivery
event The event name
occurred_at When the event happened, in UTC
data.contact The contact the event is about, as it is when the event is sent. email and phone may be null
data.subject What the event is about, as kind:ID. Use it with the contact ID to recognise the record
data.amount_minor The amount in the currency's smallest unit, so 125050 is 1,250.50. null when there is no amount
data.currency The currency code, or null

Names are sent as UTF-8 characters, not escaped, so Arabic names arrive as Arabic text.

Verify the signature

Every request is signed with the secret you copied when you added the webhook. To check it:

  1. Split X-Webhook-Signature on commas to get t and v1.
  2. Join t, a full stop and the raw request body, exactly as received, before any JSON parsing.
  3. Compute the HMAC-SHA256 of that text with the whole secret as the key, in lower-case hexadecimal.
  4. Compare it with v1 in constant time. Reject the request if they differ.
  5. Reject the request if t, a Unix time in seconds, is more than a few minutes from your clock. Five minutes is a sensible window.

t is the time of this attempt, so a retry carries a new t and a new signature.

PHP

<?php

$secret = getenv('WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $piece) {
    [$name, $value] = array_pad(explode('=', $piece, 2), 2, '');
    $parts[$name] = $value;
}

$time = $parts['t'] ?? '';
$expected = hash_hmac('sha256', $time . '.' . $body, $secret);

if (! ctype_digit($time) || abs(time() - (int) $time) > 300 || ! hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// Skip $event['id'] if you have already handled it, then do the work.
http_response_code(200);

JavaScript (Node.js with Express)

const crypto = require('crypto');
const express = require('express');

const app = express();
const secret = process.env.WEBHOOK_SECRET;

app.post('/incoming', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Webhook-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((piece) => piece.split('=')));
  const time = Number(parts.t);
  const expected = crypto.createHmac('sha256', secret).update(parts.t + '.').update(req.body).digest();
  const given = Buffer.from(parts.v1 || '', 'hex');
  const fresh = Number.isInteger(time) && Math.abs(Date.now() / 1000 - time) <= 300;

  if (!fresh || given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Skip event.id if you have already handled it, then do the work.
  res.sendStatus(200);
});

app.listen(3000);

Answer quickly

Rule Value
Time to connect 3 seconds
Time for the whole request 5 seconds
Success Any 2xx status. The body is ignored
Redirects Not followed. A 3xx counts as a failure

Answer 200 as soon as you have checked the signature and stored the event, then do the slow work afterwards. Anything that takes longer than 5 seconds is a failure, even if your system finishes the work.

Retries

Events are sent by a process that runs every minute and sends up to 50 due events per company each time. A failed attempt is retried up to 5 attempts in all:

Attempt When
1 Within about a minute of the event
2 About 1 minute after attempt 1
3 About 5 minutes after attempt 2
4 About 30 minutes after attempt 3
5 About 2 hours after attempt 4

A timeout, a connection error, a non-2xx status, a redirect or a host that now resolves to an internal address all count as a failed attempt. After the fifth failure the event is marked Failed and is not sent again.

Because an event can arrive more than once, for example when your answer was lost on the way back, use the id to handle each delivery only once. Events can also arrive out of order after retries, so rely on occurred_at rather than the order of arrival.

Automatic pause

When 10 events in a row use up all their attempts without a single success in between, the address is paused and shows Paused. A success resets the count, and so does pausing or resuming by hand.

While an address is paused, events that happen are not queued for it at all, and they are never sent later. Events that were already waiting when it was paused stay waiting: select Resume on the Webhooks screen to send them. Events still waiting after 30 days are dropped and marked Skipped instead of being sent late.

The delivery log

The Recent events list on the Webhooks screen shows the latest 20 events across all addresses, with their state and the status code your server returned:

State Meaning
Waiting Queued, or waiting for its next attempt
Delivered Your server answered 2xx
Failed All 5 attempts failed
Skipped Not sent: the contact was erased before sending, the event waited more than 30 days, or its address was removed

Delivery records are kept for 30 days. The log never stores the contact's details: they are read when the event is sent, which is also why an erased contact is never sent.

Good to know

  • There is no way to see or change a signing secret after it is shown. To use a new secret, add the address again, update your system with the new secret, then remove the old entry. Until you remove it, both entries count towards the 5 addresses.
  • There is no test button. Trigger a real event, for example by marking a test deal won, and watch Recent events.
  • If the plan loses API access, events that happen meanwhile are not queued at all, and waiting ones are not sent until it returns.

Troubleshooting

Problem What to check
Nothing arrives and the log shows Waiting The address is paused, or the plan lacks API access. Events can take up to a minute to leave
Failed with no status code The connection failed or timed out, or the host resolves to a private or IPv6-only address
Failed with 301 or 302 Your address redirects. Use the final address
Signatures never match You are signing parsed and re-encoded JSON. Use the raw body, and the whole secret as the key
The address shows Paused 10 events in a row failed. Fix the receiver, then select Resume

Last updated 2026-11-08