Webhooks
Keep your customer experience connected across systems. Outgoing webhooks tell your application when a record or email event changes; incoming webhooks let your application start a maxclicks journey.
- Outgoing: maxclicks POSTs signed JSON to your URL when a record changes or an email event happens. You manage these in the app under Settings, then Developers, then Webhooks, or over the API at
/v1/webhooks. - Incoming: your system POSTs to a workflow trigger URL to start a run.
Triggers
An outgoing webhook connects one trigger to one URL. You set the trigger when you create the webhook, and it cannot be changed afterward.
| Trigger | Fires when | schemaId |
|---|---|---|
contact upserted | a contact is created or updated | contact schema, required |
contact deleted | a contact is deleted | contact schema, required |
object upserted | an object is created or updated | object schema, required |
object deleted | an object is deleted | object schema, required |
event fired | an event fires | event schema, required |
email event | an email reaches a lifecycle state | contact schema, optional |
upserted covers both creation and every update. There is no trigger that fires on creation alone.
For email event, list the states you want in emailEventTypes, which cannot be empty: scheduled, sending failed, sent, rejected, unsubscribed, bounced, complained, delivered, opened, clicked, delivery delayed. schemaId is optional here, so you also receive events for email sent to plain addresses that are not contacts.
Conditions
A condition narrows down which records fire the webhook.
type | Effect |
|---|---|
none | Every record in the schema fires it. |
segment | Only contacts in the segmentId segment fire it. |
custom filter | A filter maxclicks builds from requirements, which you write in plain language. |
Writing a custom filter calls AI, and it counts against your AI rate limit. Your credits are checked first (402 insufficient_credits), and only the readable description is stored: the filter maxclicks generates is never returned.
For a contact-upsert trigger with a condition, the webhook responds when a contact enters the matching state and stays quiet on later saves while it still matches. With none, every creation or update can trigger it. Deletion conditions are checked against the deleted record's data. A broad trigger can therefore turn an import or bulk edit into many deliveries.
Delivery
maxclicks sends a POST with the raw JSON event as the body and these headers. A retry preserves the delivery identity:
| Header | Value |
|---|---|
X-Webhook-ID | Delivery id, wr_... |
X-Webhook-Timestamp | ISO 8601 time the delivery was signed |
X-Webhook-Signature | v1,<hex> |
Content-Type | application/json |
Idempotency-Key | Same stable delivery ID |
X-Maxclicks-Attempt | Current attempt number |
The body is the event itself. Unlike API responses, it is not wrapped in a data object.
What is in the body depends on what happened:
// contact upserted / deleted
{ "contact": { "id": "...", "email": "[email protected]", "firstName": "Jane" } }
// event fired
{ "event": { "id": "...", "eventId": "order-9981", "createdAt": "2026-01-15T10:30:00.000Z" } }
// email event
{ "contact": { "id": "..." }, "email": { "id": "..." }, "event": { "type": "opened" } }
Records carry stored attributes only. Evaluated attributes, the ones maxclicks works out on the fly, are left out. Public record reads do not automatically expand computed values either. Agree the explicit stored data your receiver needs before relying on a field in the payload.
Verify the signature
Check the signature on every delivery, so you know it came from maxclicks. X-Webhook-Signature is v1, followed by the hex HMAC-SHA256 of id.timestamp.body: the delivery id, a literal ., the timestamp, a literal ., then the exact request body. The key is the webhook's signatureVerificationSecret (whsec_...), returned once when you create the webhook and again when you rotate the secret.
Compute the HMAC over the raw request bytes, before any JSON parse or
re-serialize. Sign the full id.timestamp.body message, not the body alone.
import crypto from 'node:crypto'
function isValidDelivery(headers, rawBody, secret, now = Date.now()) {
const id = headers['x-webhook-id']
const timestamp = headers['x-webhook-timestamp']
const signature = headers['x-webhook-signature']
if (![id, timestamp, signature].every((value) => typeof value === 'string'))
return false
const signedAt = Date.parse(timestamp)
if (!Number.isFinite(signedAt) || Math.abs(now - signedAt) > 5 * 60 * 1000)
return false
const expected =
'v1,' +
crypto
.createHmac('sha256', secret)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest('hex')
const received = Buffer.from(signature)
const computed = Buffer.from(expected)
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
)
}
Pass the raw request body as a Buffer. This example rejects missing headers, invalid timestamps, and timestamps more than five minutes from your server clock. Keep your clock synchronized and deduplicate accepted deliveries by X-Webhook-ID before applying side effects.
Reliability
Deliveries can arrive out of order and can be repeated. Persist the event durably, deduplicate by X-Webhook-ID, answer 2xx promptly, then perform slower work from your own queue. Do not acknowledge before your system has safely recorded the work.
Network failures, 408, 429 and 5xx responses are retried with backoff, up to five attempts within a 24-hour delivery window. Retry-After is honored for retryable responses when the delay fits that window. Other non-2xx responses finish the delivery as failed. This is bounded recovery, not a guarantee your endpoint eventually receives every event.
The event payload, destination URL and signing secret are saved when delivery is queued. Retries keep that snapshot and delivery ID, while each attempt receives a fresh signature timestamp. Changing a webhook URL or secret does not rewrite queued deliveries. Keep the previous verification secret available while old deliveries drain.
The consecutive-failure counter tracks terminal unhealthy deliveries, not each retry attempt. Five consecutive unhealthy completions trip the webhook to circuit breaker and stop further dispatch. Successful completion resets the counter. Exhausted 429 throttling and work suppressed because the webhook was paused do not increment that unhealthy streak, although the delivery can still be listed as failed.
A tripped webhook stays off until you turn it back on. In the app, open the webhook and press Reset and resume on its circuit breaker card; over the API, PATCH its status back to live. Either way the failure counter is cleared. Pausing or tripping the webhook can suppress pending deliveries; resuming is not a replay of that missed history. Reconcile the affected records with your receiving system. Every delivery is listed under Recent deliveries on the webhook's page: open one to read the payload that was sent, the response body that came back, the response code, the duration, and the error message if it failed.
Manage webhooks
Webhooks live in the app under Settings, then Developers, then the Webhooks tab. Create webhook is there, and opening one shows its configuration and its recent deliveries, with Test, Pause or Resume, Edit, Rotate secret and Delete on the same page. Test builds a sample payload for the trigger, posts it to your URL, and shows you the status code, headers and body that came back, which is the quickest way to check an endpoint before real traffic reaches it.
The API does the same work:
| Method | Path | Purpose |
|---|---|---|
GET | /v1/webhooks | List webhooks. |
POST | /v1/webhooks | Create a webhook. Returns the secret once. |
GET | /v1/webhooks/{id} | Get a webhook. |
PATCH | /v1/webhooks/{id} | Update url, emailEventTypes, condition, or status. |
DELETE | /v1/webhooks/{id} | Delete a webhook. |
POST | /v1/webhooks/{id}/rotate-secret | Issue a new secret for newly queued deliveries; older queued work keeps its saved secret. |
trigger and schemaId are fixed once the webhook exists, and trying to change them returns 400 webhook_immutable_field. An update can set status to live or paused, nothing else. Webhooks that belong to an integration (they carry an integrationVendorName) return 403 webhook_integration_owned on update, delete, and rotate: change those in the integration.
signatureVerificationSecret is shown on create and rotate-secret only. Save
it immediately.
curl -X POST https://api.maxclicks.ai/v1/webhooks \
-H "Authorization: Bearer $MAXCLICKS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/maxclicks",
"trigger": "contact upserted",
"schemaId": "students",
"condition": { "type": "none" }
}'
You get back the webhook itself, with id, url, trigger, emailEventTypes, schemaId, condition, status, totalCalls, failedCalls, failedCallsInARow, lastCalledAt, lastCallErrorMessage, integrationVendorName, createdAt, and updatedAt.
Incoming webhooks
Your own system can start a workflow run by POSTing to an incoming-webhook trigger step. Copy the URL from that step in the app:
https://api.maxclicks.ai/v1/workflows/{workflowStepReference}
POST your JSON body. maxclicks checks it against the step's JSON schema and starts a run, with your body as the data the run begins with. A success returns 200 and an empty data object: { "data": {} }.
This endpoint needs a Bearer API key for the
workflow's space. Requests without one are rejected. Send an Idempotency-Key
for the logical trigger and preserve it when retrying. Follow the idempotency
guide if the outcome is unresolved.
If the step does not exist, is not published, or is paused, you get 404 workflow_step_not_found. A body that does not match the step's schema returns 400.