Webhook subscriptions
A webhook subscription tells RadarSend to POST every event of one type to a URL of yours: every form submission, every new contact, every human click. It is what an integration platform such as Zapier, Make or n8n needs for an instant trigger, and it works just as well from your own code.
A subscription covers the whole account. To send a contact only when a rule of yours fires (a score crossing 50, one particular segment), use the Call a webhook action on an Automation or a sequence step instead. Both use the same request format, signature and delivery rules, described in the webhooks guide.
Subscriptions are created through this API. In the app, an admin can see them and remove one under Settings › Developer › Webhooks, where their deliveries are logged too.
Required scopes#
| Endpoint | Scope |
|---|---|
GET /api/webhooks/event-types | webhooks:read |
GET /api/webhooks/event-types/{type}/sample | webhooks:read |
GET /api/webhooks/subscriptions | webhooks:read |
GET /api/webhooks/subscriptions/{id} | webhooks:read |
POST /api/webhooks/subscriptions | webhooks:write |
DELETE /api/webhooks/subscriptions/{id} | webhooks:write |
webhooks:write implies webhooks:read. Neither scope can read a contact
through the API, but a subscription delivers contact records to the URL it
names, so grant webhooks:write as carefully as you would contacts:read.
Event types#
The catalog is fixed and the same for every account.
type | Sent when |
|---|---|
contact.created | A contact becomes one you can email, added one at a time: through the API, in the app, or by a form. A contact waiting to confirm a double opt-in, or added by a page’s identify call, sends it when it becomes active, not before. File imports, and contacts released or activated in bulk, do not send it. |
contact.updated | A contact’s record changes. changes lists the fields whose value changed; a write that changed nothing sends nothing. Changes made to many contacts at once (bulk actions, imports) do not send it, and neither do bounces and complaints. |
contact.unsubscribed | A contact unsubscribes, from everything (global is true) or from the email types in email_types. An admin suppressing contacts in bulk does not send it. |
form.submitted | A contact submits one of your forms. |
segment.entered | A contact is added to a list. For a segment, only while an Automation or sequence is watching that segment. |
segment.exited | A contact is removed from a list. For a segment, only while an Automation or sequence is watching that segment. |
sequence.completed | A contact reaches the end of a sequence. A contact who leaves early, or who meets the sequence’s goal, does not send it. |
email.clicked | A person clicks a link in an email. See Clicks and opens. |
email.opened | A person opens an email. Rarely sent: see Clicks and opens. |
webinar.registered | A contact registers for a webinar on a connected webinar platform (GoTo Webinar). Registrations found when the platform is first connected do not send it. |
webinar.attended | A contact attended a webinar. Sent once the platform publishes the session’s attendance, usually 15 minutes to a few hours after it ends. |
webinar.missed | A contact registered for a webinar and attended none of its sessions. Sent once the last session has ended and its attendance is in. |
purchase.completed | A contact paid for an order: a one-off payment, or the first payment of a new subscription. Payments found when a store is first connected do not fire it. |
purchase.refunded | All or part of an order was refunded. value is the amount refunded this time. |
subscription.renewed | A subscription was charged for another period. |
subscription.payment_failed | A subscription’s payment was declined. next_payment_attempt says when it will be retried, if it will. |
subscription.cancel_pending | A subscription was set to end at the close of its current period. It is still active until then. |
subscription.reactivated | A scheduled cancellation was undone, or a subscription that had stopped paying is active again. |
subscription.canceled | A subscription ended. ended_by is cancellation, or expired when it ended because payments failed. |
subscription.trial_ending | A subscription’s free trial ends soon. Stripe sends it three days before the trial ends. |
GET /api/webhooks/event-types
{
"data": [
{
"type": "contact.created",
"name": "Contact created",
"description": "A contact was added one at a time: through the API, in the app, or by a form. Contacts added by a file import do not fire it."
}
],
"next_cursor": null,
"has_more": false
}
Clicks and opens#
Most of the clicks a business list produces come from security scanners that
follow every link before the message reaches a person. RadarSend spends a few
minutes gathering evidence about each click and open, then settles on a
verdict. email.clicked and email.opened are sent only once that verdict is
in and it says a person did it, so:
- they arrive a few minutes after the click or open, not instantly;
- scanner clicks and automatic opens by mail software are never sent;
- the bar is your account’s own engagement threshold, the same one sequence entry triggers use.
An open is much harder to confirm than a click. Most mail apps load images
on their own or through a proxy (Gmail loads every image through Google’s),
so an open alone never shows a person. An open is confirmed only when the
same device also made a confirmed click on the email. email.opened is
therefore sent for a small share of the opens your reports count, and it is
not a reliable trigger for a workflow; email.clicked is.
Very rarely a verdict is corrected later. A click first judged a scanner and then found to be a person is sent at that point. One already sent cannot be taken back.
Subscribe#
POST /api/webhooks/subscriptions
{
"event_type": "form.submitted",
"target_url": "https://hooks.zapier.com/hooks/standard/123456/abcdef",
"filter": { "form_id": "0b9d6f0e-7a51-4c1c-8f43-5f6a7b8c9d0e" },
"hmac_secret": "a-long-random-string"
}
| Field | |
|---|---|
event_type | Required. One type from the catalog. |
target_url | Required. An http or https URL, up to 2,048 characters. Private and internal addresses are refused. |
filter | Optional. Narrows the subscription to one form, list, segment or sequence. See Filters. |
hmac_secret | Optional, 16 characters or more. When set, every delivery is signed with it. It is stored encrypted and never returned. |
Returns 201 with the subscription:
{
"id": "5f0e7c0a-…",
"event_type": "form.submitted",
"target_url_display": "https://hooks.zapier.com/…",
"filter": { "form_id": "0b9d6f0e-7a51-4c1c-8f43-5f6a7b8c9d0e" },
"filter_target_missing": false,
"has_hmac_secret": true,
"active": true,
"disabled_reason": null,
"disabled_at": null,
"created_by_api_key_id": "c1a4…",
"created_by_service_account_id": "9be2…",
"created_by_user_id": null,
"created_at": "2026-10-12T14:00:00.000Z",
"updated_at": "2026-10-12T14:00:00.000Z"
}
Keep the id: it is what you pass to unsubscribe, and it is the only way to
tell two subscriptions on the same host apart. The URL is never returned.
target_url_display holds its scheme and host only, because the rest of a
hook URL is usually a secret, and a key that can only read should not be able
to collect every hook in the account.
Subscribing is idempotent. The same target_url and event_type again returns
the existing subscription with 200 rather than creating a second one, so a
retried request never leaves you receiving each event twice. It also switches
the subscription back on if it had been switched off,
and replaces its signing secret and its filter with the ones in the request.
A new subscription starts receiving events within about a minute. An account can hold up to 100 active subscriptions; ones that have been switched off do not count.
| Status | error.code | Meaning |
|---|---|---|
400 | INVALID_SUBSCRIPTION | event_type is not in the catalog, target_url is not a URL RadarSend will deliver to, or the filter does not fit the event type or names something that does not exist. |
409 | SUBSCRIPTION_LIMIT_REACHED | The account already holds 100 active subscriptions. |
Filters#
Without a filter a subscription receives every event of its type. A filter narrows it to one thing, so a workflow about one form is not started by every other form:
| Event type | Filter | Value |
|---|---|---|
form.submitted | form_id | A form’s id, from GET /api/forms |
segment.entered, segment.exited | segment_key | A list or segment key, from GET /api/segments |
sequence.completed | sequence_id | A sequence’s id, from GET /api/sequences |
The other event types take no filter. A filter key the event type does not
take, or a form, list, segment or sequence that does not exist in the account,
is refused with 400 INVALID_SUBSCRIPTION rather than ignored, because a filter
that can never match is a subscription that never fires.
If the form, list or sequence is deleted later, the subscription stays on but
can no longer fire. Reads say so with filter_target_missing: true; remove it
and subscribe again with a new filter.
The subscription returns its filter as stored. For a list or segment that is
both segment_id and segment_key:
"filter": { "segment_id": "9a8b7c6d-…", "segment_key": "trial-users" }
One URL holds one subscription per event type, so to watch two forms from the
same receiver, give each its own URL (Zapier and Make do this for you), or
subscribe once without a filter and check data.trigger.form_id.
Unsubscribe#
DELETE /api/webhooks/subscriptions/{id}
Returns 204. Deliveries stop at once: anything still queued for the
subscription is dropped, not sent. 404 if there is no such subscription.
List subscriptions#
GET /api/webhooks/subscriptions
GET /api/webhooks/subscriptions?event_type=form.submitted
GET /api/webhooks/subscriptions/{id}
The list returns every subscription in the account, newest first, in the usual
list shape. It is not paginated. Subscriptions
that have been switched off are included, with active: false and the reason,
for 30 days. After that they are removed.
Sample delivery#
GET /api/webhooks/event-types/{type}/sample
Returns a one-item list holding an example of what a subscription to {type}
receives, with invented values. Use it to map fields before a real event has
arrived. In a Zapier integration this is the trigger’s performList. An
unknown {type} returns 404.
{
"data": [
{
"id": "00000000-0000-4000-8000-000000000001",
"spec_version": "1",
"type": "form.submitted",
"occurred_at": "2026-10-12T14:03:11.000Z",
"tenant_id": "…",
"source": {
"kind": "subscription",
"id": "00000000-0000-4000-8000-000000000002",
"name": null,
"node_id": null,
"enrollment_id": null,
"trigger_type": null,
"trigger_config": null,
"event_id": null,
"execution_id": null
},
"data": {
"contact": {
"id": "c0ffee00-1234-4abc-8def-0123456789ab",
"external_id": "crm_48213",
"email": "ada@example.com",
"first_name": "Ada",
"last_name": "Lovelace",
"company": "Analytical Engines",
"title": "Head of Operations",
"phone": null,
"stage_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"stage_slug": "lead",
"tags": ["newsletter"],
"custom_fields": { "plan": "trial" },
"prospect_score": 55,
"customer_score": 0,
"global_status": "active",
"created_at": "2026-09-30T09:12:44.000Z"
},
"trigger": {
"summary": "Submitted Request a demo",
"form_id": "0b9d6f0e-7a51-4c1c-8f43-5f6a7b8c9d0e",
"form_name": "Request a demo",
"form_slug": "request-a-demo",
"submission_id": "4e0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
"source_url": "https://example.com/demo",
"fields": {
"email": "ada@example.com",
"first_name": "Ada",
"company": "Analytical Engines",
"team_size": "11-50",
"heard_about_us": "A colleague",
"marketing_consent": true,
"utm_source": "linkedin"
},
"field_labels": {
"email": "Email",
"first_name": "First name",
"company": "Company",
"team_size": "Team size",
"heard_about_us": "How did you hear about us?",
"marketing_consent": "Send me product news"
}
}
}
}
],
"next_cursor": null,
"has_more": false
}
What a delivery contains#
Every delivery is a JSON POST in the same envelope the Call a webhook
action sends:
ididentifies this delivery and is also sent asX-Webhook-Event-Id. It is the same on every retry, and the same if RadarSend ever announces one event twice, so use it to ignore duplicates.typeis the event type, also sent asX-Webhook-Event-Type.source.kindissubscriptionandsource.idis the subscription’s id, also sent asX-Webhook-Source-Id. Foremail.clickedandemail.opened,source.event_idis the id of the click or open.data.contactis the contact’s record when the delivery was built, including every custom field by its key.data.triggersays what happened. It always has asummarysentence, plus the values behind it:
type | data.trigger fields |
|---|---|
contact.created | none beyond summary |
contact.updated | changes (field names), and action for a suppress, restore or anonymize |
contact.unsubscribed | global, method, email_types, email_type_names, broadcast_id, sequence_id |
form.submitted | form_id, form_name, form_slug, submission_id, source_url, fields, field_labels |
segment.entered, segment.exited | segment_id, segment_name |
sequence.completed | sequence_id, enrollment_id |
email.clicked | event_id, url, broadcast_id, clicked_at |
email.opened | event_id, broadcast_id, opened_at |
webinar.registered | webinar_id, webinar_title, provider, starts_at, join_url, registration_id |
webinar.attended | webinar_id, webinar_title, provider, starts_at, attended_minutes, registration_id |
webinar.missed | webinar_id, webinar_title, provider, starts_at, registration_id |
purchase.completed | order_id, value, currency, product_names, subscription_id, source |
purchase.refunded | order_id, value (the amount refunded this time), currency, source |
subscription.renewed | subscription_id, product_name, value, currency, source |
subscription.payment_failed | order_id, subscription_id, value, currency, attempt_count, next_payment_attempt, source |
subscription.cancel_pending, subscription.reactivated | subscription_id, product_name, source |
subscription.canceled | subscription_id, product_name, ended_by, reason, source |
subscription.trial_ending | subscription_id, product_name, trial_end, source |
broadcast_id is null for an email sent by a sequence.
The purchase and subscription events come from a connected Stripe account,
and also from purchases your own code reports through the
events API with a typed activity. source says which:
stripe, or the reporter for an API purchase. Amounts are in major units
(dollars, not cents), and value is null when the reporter sent none.
History found when a store is first connected sends none of them.
A form’s fields holds every answer in the submission, keyed by the form’s
field key, including questions the form doesn’t save to the contact and hidden
inputs such as UTM values. A consent box is true or false; a multi-select
is a list of strings; a field the visitor never saw (hidden by a show-when
rule) is absent. Keys come in the form’s order, then any others alphabetically.
field_labels gives each key’s label as the form shows it now (a field since
removed from the form keeps its contact property’s name); a hidden input has
none. Both are empty objects when the submission no longer exists, for example
because the contact was erased.
New fields may be added to the envelope and to data.trigger without notice.
Nothing listed here will be removed or change meaning without a new
spec_version.
Signing works exactly as it does for the webhook action: with an hmac_secret,
RadarSend sends X-Webhook-Timestamp and an X-Webhook-Signature holding the
hex HMAC-SHA256 of the timestamp, a period and the raw body. See
Verify the signature.
When deliveries fail#
Reply with any 2xx within 5 seconds. A failed delivery is tried up to 3
times in all; timeouts, 5xx and 429 are retried, any other 4xx is not.
Three things switch a subscription off. It stays in the list, with
active: false, and receives nothing more:
disabled_reason | What happened |
|---|---|
target_gone | Your endpoint answered 410 Gone. RadarSend takes that as “this hook no longer exists” and stops at once. This is how Zapier, Make and n8n report a deleted workflow. |
delivery_failing | After 10 failures in a row to one URL, RadarSend pauses it, holds new deliveries and tries again every 5 minutes. If a delivery has waited 24 hours and the URL still has not recovered, the subscription is switched off. |
credential_revoked | The API key that created it was revoked or expired, or the OAuth connection it came from was disconnected, and its service account has no key left that works. Deliveries already queued are dropped. Rotating a key keeps the subscription. Subscribing again with a working key switches it back on under that key. |
To switch one back on, subscribe the same URL to the same event type again, within 30 days; after that it has been removed and subscribing creates a new one. Events that happened while it was off are not sent later.
Any other failure leaves the subscription on. A 404 or a 401 might be a
deploy in progress or an expired token, and removing the subscription for it
would turn a short outage into a silent, permanent one.
Good to know#
- At least once. A delivery can arrive twice (a retry after a reply that
came too late). Ignore an
idyou have already handled. - Not in order. Deliveries go out several at a time. Use
occurred_atif order matters. - Deliveries are in the log. Each one appears in the app under Settings › Developer › Webhooks, with Subscription as its source. The log keeps 30 days.
- Filters cover forms, lists, segments and sequences only. For anything
finer (one tag, one link, one email), check
data.triggeranddata.contactin your receiver, or use an Automation with the Call a webhook action.