Docs
API reference Contacts & audience Webhook subscriptions

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#

EndpointScope
GET /api/webhooks/event-typeswebhooks:read
GET /api/webhooks/event-types/{type}/samplewebhooks:read
GET /api/webhooks/subscriptionswebhooks:read
GET /api/webhooks/subscriptions/{id}webhooks:read
POST /api/webhooks/subscriptionswebhooks: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.

typeSent when
contact.createdA 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.updatedA 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.unsubscribedA 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.submittedA contact submits one of your forms.
segment.enteredA contact is added to a list. For a segment, only while an Automation or sequence is watching that segment.
segment.exitedA contact is removed from a list. For a segment, only while an Automation or sequence is watching that segment.
sequence.completedA contact reaches the end of a sequence. A contact who leaves early, or who meets the sequence’s goal, does not send it.
email.clickedA person clicks a link in an email. See Clicks and opens.
email.openedA person opens an email. Rarely sent: see Clicks and opens.
webinar.registeredA 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.attendedA contact attended a webinar. Sent once the platform publishes the session’s attendance, usually 15 minutes to a few hours after it ends.
webinar.missedA contact registered for a webinar and attended none of its sessions. Sent once the last session has ended and its attendance is in.
purchase.completedA 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.refundedAll or part of an order was refunded. value is the amount refunded this time.
subscription.renewedA subscription was charged for another period.
subscription.payment_failedA subscription’s payment was declined. next_payment_attempt says when it will be retried, if it will.
subscription.cancel_pendingA subscription was set to end at the close of its current period. It is still active until then.
subscription.reactivatedA scheduled cancellation was undone, or a subscription that had stopped paying is active again.
subscription.canceledA subscription ended. ended_by is cancellation, or expired when it ended because payments failed.
subscription.trial_endingA 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_typeRequired. One type from the catalog.
target_urlRequired. An http or https URL, up to 2,048 characters. Private and internal addresses are refused.
filterOptional. Narrows the subscription to one form, list, segment or sequence. See Filters.
hmac_secretOptional, 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.

Statuserror.codeMeaning
400INVALID_SUBSCRIPTIONevent_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.
409SUBSCRIPTION_LIMIT_REACHEDThe 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 typeFilterValue
form.submittedform_idA form’s id, from GET /api/forms
segment.entered, segment.exitedsegment_keyA list or segment key, from GET /api/segments
sequence.completedsequence_idA 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:

  • id identifies this delivery and is also sent as X-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.
  • type is the event type, also sent as X-Webhook-Event-Type.
  • source.kind is subscription and source.id is the subscription’s id, also sent as X-Webhook-Source-Id. For email.clicked and email.opened, source.event_id is the id of the click or open.
  • data.contact is the contact’s record when the delivery was built, including every custom field by its key.
  • data.trigger says what happened. It always has a summary sentence, plus the values behind it:
typedata.trigger fields
contact.creatednone beyond summary
contact.updatedchanges (field names), and action for a suppress, restore or anonymize
contact.unsubscribedglobal, method, email_types, email_type_names, broadcast_id, sequence_id
form.submittedform_id, form_name, form_slug, submission_id, source_url, fields, field_labels
segment.entered, segment.exitedsegment_id, segment_name
sequence.completedsequence_id, enrollment_id
email.clickedevent_id, url, broadcast_id, clicked_at
email.openedevent_id, broadcast_id, opened_at
webinar.registeredwebinar_id, webinar_title, provider, starts_at, join_url, registration_id
webinar.attendedwebinar_id, webinar_title, provider, starts_at, attended_minutes, registration_id
webinar.missedwebinar_id, webinar_title, provider, starts_at, registration_id
purchase.completedorder_id, value, currency, product_names, subscription_id, source
purchase.refundedorder_id, value (the amount refunded this time), currency, source
subscription.renewedsubscription_id, product_name, value, currency, source
subscription.payment_failedorder_id, subscription_id, value, currency, attempt_count, next_payment_attempt, source
subscription.cancel_pending, subscription.reactivatedsubscription_id, product_name, source
subscription.canceledsubscription_id, product_name, ended_by, reason, source
subscription.trial_endingsubscription_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_reasonWhat happened
target_goneYour 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_failingAfter 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_revokedThe 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 id you have already handled.
  • Not in order. Deliveries go out several at a time. Use occurred_at if 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.trigger and data.contact in your receiver, or use an Automation with the Call a webhook action.