Skip to main content
About this page · Updated 2026-10-26 11:49 UTC

What this page is: Be told when a campaign completes, a message is delivered, bounces or draws a complaint, or a contact unsubscribes or gives consent. Signed, retried, and safe by design.

How far to trust this page: the build checked 1 names, paths, tools, scopes and error codes against the real product (0 do not match). Not yet reviewed claim by claim.

Updated 2026-10-26 11:49 UTCLatest change: Communications: webhooks, your own unsubscribe landing page, and a required unsubscribe linkRecently updated pages

Webhooks

A webhook is a URL on your server that Motorical calls when something happens in a workspace, so you can keep your own system in step without polling: remove an unsubscribed contact from your CRM, flag a bounce, start a follow-up when a campaign finishes.

Requests need an account API key and your workspace ID in X-Tenant-Id: see Authentication rules. The workspace ID in the path ({id}) must be the same workspace as X-Tenant-Id; anything else is 404 WORKSPACE_NOT_FOUND.

:::note Not the same as Motor Block webhooks Motor Block webhooks report the delivery of individual API-sent messages. These workspace webhooks report campaign events and carry the campaign and contact, which Motor Block webhooks cannot. They are signed the same way, so one verifier works for both. :::

Create a webhook​

curl -X POST https://motorical.com/comm-api/api/workspaces/YOUR_WORKSPACE_ID/webhooks \
-H "Authorization: ApiKey ak_live_YOUR_KEY" \
-H "X-Tenant-Id: YOUR_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/motorical", "events": ["contact.unsubscribed", "message.bounced"]}'

The 201 response returns the signing secret once. It is never shown again; if you lose it, delete the webhook and create a new one.

{
"success": true,
"data": {
"id": "5b0e7e0a-3f0c-4c1e-9a43-0f6f4a1d2c77",
"url": "https://example.com/hooks/motorical",
"events": ["contact.unsubscribed", "message.bounced"],
"enabled": true,
"secret": "9f2c...64 hex characters...e1",
"created_at": "2026-10-26T09:00:00.000Z"
}
}

The URL must be https, must not contain a username or password, and must point at a public address. Addresses inside private networks, loopback and link-local addresses are refused, and so is a hostname that resolves to one. This is checked when you create or change a webhook and again before every delivery.

CodeWhy
400 INVALID_EVENTSevents is missing, empty, or contains a name that is not in the list below.
400 WEBHOOK_URL_UNSAFEThe URL is not https, has credentials, or is not a public address.
409 WEBHOOK_LIMITThe workspace already has 10 webhooks.
404 WORKSPACE_NOT_FOUND{id} is not the workspace in X-Tenant-Id.

Manage webhooks​

CallDoes
GET .../workspaces/{id}/webhooksLists the webhooks: id, url, events, enabled, failure_count, last_delivery_status. The secret is never returned.
PATCH .../workspaces/{id}/webhooks/{webhookId}Changes url, events or enabled. Setting enabled to true resets the failure count.
DELETE .../workspaces/{id}/webhooks/{webhookId}Deletes the webhook and anything not yet delivered to it.
POST .../workspaces/{id}/webhooks/{webhookId}/testQueues one event named test (202), sent like any other delivery.

An unknown webhookId is 404 WEBHOOK_NOT_FOUND.

Events​

EventWhendata
campaign.completedA sending campaign has nothing left to send.campaign_id
message.deliveredThe recipient's server accepted a campaign message.campaign_id, contact_id, email, message_id
message.bouncedA campaign message bounced.same as above
message.complainedThe recipient marked a campaign message as spam.same as above
contact.unsubscribedA contact unsubscribed, by any route. Sent once, the first time.campaign_id (null if not from a campaign), contact_id, email, source
contact.consent_givenA contact's consent was newly recorded.campaign_id (null if none), contact_id, email, source

source for an unsubscribe is one_click, link, manual or api (see How a recipient unsubscribes). For consent it names how consent was given, for example link_confirmation.

Opens and clicks are not events: many are fetched by machines, so they would be noisy. Read them from the tracking endpoints when you need them.

Only events that happen after a webhook exists are sent. Nothing is replayed.

What a delivery looks like​

Every delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
X-Motorical-EventThe event name, for example contact.unsubscribed.
X-Motorical-SignatureHex HMAC-SHA256 of the raw request body, keyed with your webhook secret.
{
"event": "contact.unsubscribed",
"id": "0c6f2f4e-8d0a-4a54-9a37-2f8d4f7a1b10",
"workspace_id": "YOUR_WORKSPACE_ID",
"occurred_at": "2026-10-26T09:15:02.113Z",
"data": {
"campaign_id": "7a2d5e0b-1c3f-4d6a-8b90-5e4f3a2b1c0d",
"contact_id": "d4c3b2a1-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
"email": "ada@example.com",
"source": "one_click"
}
}

email is the recipient's address in plain text; treat it as personal data. id is unique to the delivery and is the same on every retry of it, so use it to ignore a repeat.

Verify the signature​

Always verify before trusting a request. The signature is computed over the exact bytes of the body, so verify against the raw body, not a re-serialised copy of the parsed JSON.

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/hooks/motorical', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', process.env.MOTORICAL_WEBHOOK_SECRET)
.update(req.body) // the raw Buffer
.digest('hex');
const given = String(req.get('X-Motorical-Signature') || '');
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.sendStatus(401);

const event = JSON.parse(req.body.toString('utf8'));
// queue `event` for background work, then answer quickly
res.sendStatus(204);
});

Delivery, retries and disabling​

  • Answer with a 2xx within 10 seconds. Do the real work in the background.
  • A redirect is a failure. Redirects are not followed.
  • Failed deliveries are retried after 1, 5, 15 and 60 minutes, and then given up on.
  • A webhook that keeps failing is switched off. After 10 consecutive failures it is disabled and its waiting events are dropped. A success resets the count. Turn it back on with PATCH and "enabled": true once your endpoint works.
  • Delivery is at least once and not strictly ordered. Make your handler safe to run twice for the same id, and do not rely on events arriving in the order they happened.
  • Events are sent within moments of being recorded, not at the instant: allow a short delay, longer for message.* events, which arrive as delivery results are collected.