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.
| Code | Why |
|---|---|
400 INVALID_EVENTS | events is missing, empty, or contains a name that is not in the list below. |
400 WEBHOOK_URL_UNSAFE | The URL is not https, has credentials, or is not a public address. |
409 WEBHOOK_LIMIT | The workspace already has 10 webhooks. |
404 WORKSPACE_NOT_FOUND | {id} is not the workspace in X-Tenant-Id. |
Manage webhooks
| Call | Does |
|---|---|
GET .../workspaces/{id}/webhooks | Lists 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}/test | Queues one event named test (202), sent like any other delivery. |
An unknown webhookId is 404 WEBHOOK_NOT_FOUND.
Events
| Event | When | data |
|---|---|---|
campaign.completed | A sending campaign has nothing left to send. | campaign_id |
message.delivered | The recipient's server accepted a campaign message. | campaign_id, contact_id, email, message_id |
message.bounced | A campaign message bounced. | same as above |
message.complained | The recipient marked a campaign message as spam. | same as above |
contact.unsubscribed | A contact unsubscribed, by any route. Sent once, the first time. | campaign_id (null if not from a campaign), contact_id, email, source |
contact.consent_given | A 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:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Motorical-Event | The event name, for example contact.unsubscribed. |
X-Motorical-Signature | Hex 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
2xxwithin 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
PATCHand"enabled": trueonce 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.