Developers

Webhooks

A webhook endpoint (POST /webhooks) receives the public events of your account (the same ones returned by GET /events) as HTTPS POST requests, one event per request, signed with a signing secret specific to that endpoint. Delivery is at least once, so your receiver must be idempotent on the event id. Events are not guaranteed to arrive in order. Events caused by a sale, an import, or a delivery, such as user.created, enrollment.created, and user.tags_updated, can arrive a few minutes later than events for changes made in the dashboard or through the API. enrollment.updated is also sent when a sales platform suspends or reactivates an enrollment, through a cancellation, a refund, a chargeback, or a subscription renewal; a cancellation scheduled for a later date is sent when it takes effect.

For every field and parameter, see the Webhooks reference. For the event catalog, see Events. For the body of each event type, see the Webhooks v3 reference. To create and manage endpoints without the API, see Set up in the dashboard.

Registering an endpoint

  • url must use https and point to a public host on port 443, 80, or 8443. URLs that point to private, loopback, link-local, or metadata addresses are rejected when you create or update the endpoint. The platform checks the resolved address again before every delivery, so a host that resolves to a non-public address makes the delivery fail.
  • event_types[] lists the event types to subscribe to, taken from the catalog documented by GET /events. An endpoint created with API credentials receives events only while those credentials still have the events.read permission.
  • resource_filters optionally narrows deliveries by resource. Each key is the resource type of an event type, as listed in the Resource column of the event catalog, such as product or lesson. Each value lists the IDs of that type to deliver, as public IDs (prd_7) or numeric IDs (7); IDs are returned in numeric form. An ID that does not belong to the resource type of its key returns 422 validation_failed. A filter applies only to events whose own resource is of that type, and event types whose resource type is not a key are delivered without restriction. null removes all filters.
    • lesson_progress.completed has the resource lesson, while product_progress.started, product_progress.completed, product_progress.milestone_reached, and product_progress.updated have the resource product. A product filter therefore does not restrict lesson_progress.completed: list the lessons under lesson to narrow it.
  • event_filters optionally narrows deliveries further, by fields of the event data of each subscribed event type. See Filtering by event type.
  • description is free text.
  • payload_detail sets how much detail each delivery carries: ids (the default), masked, or full. See Payload detail level.
  • The create response includes the secret (whsec_…) only once. It cannot be retrieved afterwards. POST /webhooks/{webhook_id}/secret-rotations generates a new signing secret and keeps the previous one valid for overlap_hours (24 by default, up to 72). During that period, each delivery carries one signature per active secret.
  • timeout_ms (10,000 by default) and max_per_second (10 by default) are read-only through the API. GET /webhooks/{webhook_id} reports their current values. timeout_ms is the time limit of each request, applied between 1 and 10 seconds.
  • status is active or inactive. Changing it with PATCH requires the webhooks.activate permission. Setting it to inactive pauses the endpoint: its pending deliveries wait and resume when you set it back to active, and events that occur while it is inactive are not queued for it. A paused delivery still becomes dead 72 hours after it was created. DELETE cancels pending deliveries; the delivery history is kept for the retention period. After DELETE, GET /webhooks/{webhook_id} returns 404, as the list leaves the endpoint out.

Payload and headers

Body: {"id": "evt_…", "type": "…", "version": 1, "occurred_at": "…", "data": {...}, "delivery_id": "whd_…", "attempt": n}. When the endpoint's payload_detail is masked or full, an expanded object follows data.

Headers: Cademi-Signature: t=<unix>,v1=<hex>[,v1=<hex>], Cademi-Signature-Version: 1, Cademi-Webhook-Id (whk_…), Cademi-Delivery-Id (whd_…), Cademi-Event-Type, User-Agent: Cademi-Webhooks/3, Content-Type: application/json.

Payload detail level

payload_detail applies to every event type the endpoint subscribes to. Set it with POST /webhooks or PATCH /webhooks/{webhook_id}; GET /webhooks and GET /webhooks/{webhook_id} return it. For the body you receive at each level, see PII payloads.

LeveldataexpandedPersonal data
idsYes, identifies people by public IDNot includedNo personal data stored by Cademí, except the reason noted below
maskedYesYes, with personal fields maskedMasked personal data
fullYesYes, with values as storedFull personal data

At every level, the reason an administrator writes in certificate.revoked and diamond_membership.stage_changed is sent as written.

expanded holds the resources the event refers to, such as user, product, or lesson, in the same format the API returns them. Products, lessons, and exams use the format of their listing operations: for a product, display and definitions are empty objects, and offer is not included. expanded is built when the delivery is created, so every attempt of a delivery sends the same objects. Every key of the event type is always present, and any of them can be null when the object is not available, for example because the resource was deleted: the event is still delivered, and you can fetch the resource from the API by its ID in data. An event type with no expandable resource sends expanded: {}. The Webhooks v3 reference lists the keys of expanded for each event type.

Masking rules

In masked, each personal field keeps its type and is masked by a fixed rule, so the same value is always masked the same way:

FieldMasked asExample
NameThe first word is kept; every other word becomes its first letter followed by **Renan C** P**
EmailThe first two characters of the user part (one, if it has two or fewer) and the first letter of the first domain label, each followed by **, then the other domain labelsre**@g**.com
Phone, 10 or more digitsThe area code in parentheses, *****, and the last four digits. A leading 55 in a 12- or 13-digit number is dropped first(13) *****8923
Phone, 4 to 9 digits***** and the last four digits
Phone, fewer than 4 digits****
DocumentOnly the last two digits are kept. An 11-digit document (CPF) becomes ***.***.***- and those digits; any other length gets one * for each other digit***.***.***-09
external_id, the IP address and user agent of a legal term acceptance, the address and custom field values recorded in a certificate, and free text written by users (comment text, question body, ticket subject)Fully replaced[redacted]

null stays null, empty text stays empty, a name made only of spaces is sent as is, an email without @ is masked as a user part only, and a document without digits becomes empty.

Masked personal data is still personal data: handle it as such. A masked value may not match the format declared for its field; for example, a masked email is not a valid address.

Permission for full

Setting payload_detail to full, or changing the event types of an endpoint whose payload_detail is full, requires the users.read_personal permission. It also requires tickets.read_personal when a subscribed event type expands a ticket (the ticket.* events), and legal_terms.read_proof when one expands a term_acceptance (term_acceptance.created). Without a permission the change requires, the request fails with 403 personal_data_permission_required, and nothing is created or changed. configuration.apply returns the same code on the affected item when a webhook in the plan needs a permission the current credentials do not have. The same rule applies when the endpoint is configured in the dashboard.

The permissions that keep full in effect are those of whoever created the endpoint: the API credentials or the dashboard administrator. Editing the endpoint later does not change this. When the creator loses one of the permissions full requires, new deliveries are sent as masked until the permission is restored. GET /webhooks/{webhook_id} returns the level new deliveries use in health.effective_payload_detail, which differs from payload_detail while this applies.

Deliveries already queued

Each delivery keeps the expanded object it was built with, and sends it only while the endpoint still accepts that level:

  • If the level goes down, by PATCH or by the fallback to masked described above, queued deliveries built at a higher level are sent without expanded.
  • If the level goes up, queued deliveries keep what they were built with: a delivery built at masked stays masked, and a delivery built at ids has no expanded.

GET /webhooks/{webhook_id}/deliveries and GET /webhooks/{webhook_id}/deliveries/{webhook_delivery_id} return in payload_detail the level each delivery was built at (ids when it has no expanded).

Every change of payload_detail, including creating an endpoint at masked or full, is recorded in GET /audit-entries with the action code webhook.payload_detail_changed and the previous and new level. The fallback to masked does not change payload_detail, so it is not recorded there; check health.effective_payload_detail instead.

Filtering by event type

event_filters narrows deliveries by fields of the event itself, on top of resource_filters. Set it with POST /webhooks, PATCH /webhooks/{webhook_id}, or the webhook kind of a configuration manifest; GET /webhooks and GET /webhooks/{webhook_id} return it, with every ID as a public ID, even when you sent it as a numeric ID.

Each key is one of the endpoint's event_types, and its value holds the filters for that type. An event is delivered only when it passes both resource_filters and every filter of its own event type; within a list, one match is enough. Event types without a key are delivered without these filters. null removes every filter, and an endpoint with no filters returns event_filters: null.

resource_filters and event_filters together

The two filters answer different questions:

  • resource_filters looks only at the resource of the event, the Resource column of the event catalog. A key restricts only the event types whose own resource is of that type, and ignores the rest of the event.
  • event_filters looks at the fields of one event type, such as the product of an exam attempt or the milestones reached.

An event is delivered only when it passes both. For example, an endpoint that subscribes to three event types:

{
  "event_types": ["exam_attempt.completed", "lesson_progress.completed", "product_progress.milestone_reached"],
  "resource_filters": { "product": ["prd_7"] },
  "event_filters": {
    "exam_attempt.completed": { "product_id": ["prd_7"], "passed": true },
    "product_progress.milestone_reached": { "milestones": [75, 90] }
  }
}
Event typeResourceDelivered
product_progress.milestone_reachedproductOnly for product 7 (resource_filters), and only for the 75% and 90% milestones (event_filters).
exam_attempt.completedexam_attemptOnly passed attempts in exams of product 7, both from event_filters. The product key of resource_filters does not apply, because the resource of this event is the attempt.
lesson_progress.completedlessonEvery completed lesson, of any product: resource_filters has no lesson key and event_filters has no key for this type. To keep only product 7, add "lesson_progress.completed": { "product_id": ["prd_7"] } to event_filters.

To narrow every event type to one product, use product_id in event_filters for each type that accepts it; resource_filters narrows only the events that are about the product itself.

Accepted filters

Only the event types below accept filters, each with a fixed set:

Event typeAccepted filters
user.createdsignup_origin
enrollment.createddelivery_id
lesson_progress.completedproduct_id, lesson_id
product_progress.startedproduct_id
product_progress.updatedproduct_id, min_percent
product_progress.milestone_reachedproduct_id, milestones
product_progress.completedproduct_id
exam_attempt.completedproduct_id, exam_id, passed
certificate.issuedproduct_id
term_acceptance.createdlegal_term_id
score.createdorigin
user.tags_updatedadded_tag_id
user.custom_field_updatedcustom_field_id, filled
comment.createdproduct_id, lesson_id
question.createdproduct_id, lesson_id

ID filters take a list of 1 to 200 IDs, as public IDs (prd_7) or numeric IDs (7), except legal_term_id, which takes public IDs only.

FilterValueMatches
product_idUp to 200 product IDsproduct_id in the event data
lesson_idUp to 200 lesson IDslesson_id in the event data
exam_idUp to 200 exam IDsexam_id in the event data
legal_term_idUp to 200 legal term IDs: trm_platform for the platform terms, trm_product_7 for the terms of product 7legal_term_id in the event data
delivery_idUp to 200 delivery IDsdelivery_id in the event data
custom_field_idUp to 200 custom field IDscustom_field_id in the event data
added_tag_idUp to 200 tag IDsAt least one tag added in that change is in the list; tags removed in the same change are not considered. user.tags_updated carries the tags of the user after the change, not the ones added, so this filter cannot be reproduced from the delivery
passedtrue or falsepassed in the event data
min_percentA number from 0 to 100The exact progress of the user is greater than or equal to it. percent in the event data is rounded to an integer, so an event with percent 50 does not pass min_percent 50 when the exact progress is 49.6
milestonesOne or more of 50, 75, 90One of the milestones of the event is in the list
signup_originpaid or freeHow the user was created. Users created by Google sign-in or by an import have neither origin, so any signup_origin excludes them
originOne or more of lesson_completed, course_started, course_progress_50, course_progress_75, course_progress_90, course_completed, certificate_issued, question, comment, exam_passedorigin in the event data. manual is not accepted: manual adjustments are delivered as score.adjusted, never as score.created
filledtrue or falsefilled in the event data: true for a change that left the custom field with a value, false for a change that cleared it

Example, an endpoint that receives only passing attempts of one exam and progress updates of one product at 50% or more:

{
  "url": "https://erp.example/hooks/cademi",
  "event_types": ["exam_attempt.completed", "product_progress.updated"],
  "event_filters": {
    "exam_attempt.completed": {"exam_id": ["exm_12"], "passed": true},
    "product_progress.updated": {"product_id": ["prd_7"], "min_percent": 50}
  }
}

The request fails with 422 validation_failed, and nothing is created or changed, when a key is not one of the endpoint's event_types or is an event type that does not accept filters, or when a filter is not accepted for its event type or has an invalid value. details[].field points to the event type or the filter, such as event_filters.exam_attempt.completed or event_filters.exam_attempt.completed.passed.

When you send event_filters in PATCH /webhooks/{webhook_id}, it replaces the previous value as a whole and is checked against the event_types the endpoint has after the change. A PATCH that changes event_types without sending event_filters drops the filters of the event types that were removed, so they do not return if you subscribe to those types again.

Test deliveries

"Send test" on the webhook endpoint page of the dashboard sends one event of a type you choose among those the endpoint subscribes to, with sample values in data and expanded, at the level new deliveries use (health.effective_payload_detail). It carries the same headers and is signed with the same active secrets as a real delivery, so you can confirm that your receiver accepts the request and verifies the signature before a real event occurs. You can send a test whatever the status of the endpoint, and the number of tests per endpoint is rate limited.

A test delivery adds test: true at the end of the envelope. Its occurred_at is the time of the test and its attempt is 1. Its id and delivery_id are new for every test and do not exist in the API: GET /events/{event_id} and GET /webhooks/{webhook_id}/deliveries/{webhook_delivery_id} return 404 not_found for them. A test delivery is sent once and never retried, is not listed among the deliveries of the endpoint, and does not count toward the circuit, health, or automatic deactivation. A real delivery never includes the test key. A test to a blocked destination is refused; when the DNS server does not answer for the host, the test reports a transport error, as a real delivery would.

Verify the signature of a test delivery the same way as any other delivery, described below. After that, either ignore it or use it to check that the shape of the event matches what your receiver expects; do not try to reconcile its id or delivery_id with the events or deliveries API.

Verifying the signature

t        = value of "t" in Cademi-Signature
expected = HMAC_SHA256(secret, t + "." + raw_body)   # lowercase hex
valid    = any "v1" in the header == expected (constant-time comparison)
           and |now - t| <= 300 seconds

Use the raw body exactly as received, without re-serializing the JSON. During a secret rotation, check every v1 in the header against the secret you hold; a single match is enough. See also Signatures.

The same verification code works during development with cademi listen --forward-to, which delivers events to a local URL with this body and these headers, signed with a local secret. Locally, Cademi-Webhook-Id is local and User-Agent identifies the CLI. See Event streams.

Responses and retries

Endpoint responseResult
2xxDelivery becomes delivered
429Delivery waits for Retry-After (seconds or an HTTP date, up to 6 hours; 60 seconds when the header is absent or cannot be parsed) and keeps its attempts: a 429 does not count against the 10-attempt limit
400 with a body that starts with Queue is full (the response Make sends when the queue of a webhook is full)Delivery waits and keeps its attempts: 1 minute after the first request, doubling with each request up to 6 hours, until the endpoint accepts it or the 72-hour limit passes
5xx, timeout, network error, or a DNS server that does not answer for the hostNew attempt with exponential backoff and jitter, capped at 6 hours. When the DNS server does not answer, the attempt records a transport_error that starts with dns_resolution_failed
Any other 4xxDelivery becomes failed (not retried)
Destination blocked (the host name does not exist, has no address, or resolves to a non-public address)Delivery becomes failed (not retried), and the attempt records destination_blocked in transport_error
10 attempts or 72 hoursDelivery becomes dead

The attempt field in the envelope counts every request sent to your endpoint, including the ones answered with 429 or Queue is full. Waiting for Retry-After, for a full queue, for the circuit, or for the host rate limit does not spend attempts, but the 72-hour limit still applies: a delivery that reaches it while waiting becomes dead.

Each request must complete within the timeout_ms of the webhook (1 to 10 seconds, 10 by default); the connection itself must be established within 5 seconds, or within timeout_ms if it is shorter. Redirects are not followed. A masked excerpt of the response body (response_excerpt, up to 4 KiB) is stored with the attempt for troubleshooting.

Circuit per URL

After three consecutive failed requests to the same URL (5xx, timeout, or connection error; a 429 does not count, and any other response resets the count), the platform opens a circuit for that URL. While the circuit is open, deliveries to that URL wait without spending attempts. After 5 minutes, a single test delivery goes out: if it fails, the circuit stays open for another 5 minutes; if the URL answers with anything other than a 5xx or a 429, the circuit closes and deliveries resume. The circuit belongs to the URL, so every webhook endpoint that points to it shares the same circuit, while endpoints that point to a different URL, even on the same host, keep receiving deliveries normally.

The request rate is limited per host, not per URL. When your endpoint answers 429, the platform lowers the rate for the whole host: to the value of the X-RateLimit-Limit or RateLimit-Limit header of the response, or to half the current rate when neither is present. The learned limit expires after 24 hours. Deliveries held back by the host rate limit wait without spending attempts.

GET /webhooks/{webhook_id} reports the circuit state in health.circuit_state: closed (delivering normally), open (deliveries wait), or half_open (a single test delivery is allowed).

Inspecting and resending deliveries

  • GET /webhooks/{webhook_id}/deliveries, GET .../deliveries/{webhook_delivery_id}, GET .../attempts, and GET /webhooks/deliveries (across all webhook endpoints accessible with the current credentials) require the webhooks.deliveries.read permission.
  • POST .../deliveries/{webhook_delivery_id}/attempts resends a delivery manually (same event id, new attempt number) and requires the webhooks.replay permission.
  • POST /webhooks/{webhook_id}/replays creates a webhook.replay operation that resends retained events already delivered to this endpoint (selected by IDs, time range, or event types). It adds attempts to the existing deliveries and never creates new ones. Track the per-event results with GET /operations/{operation_id}. See Operations.
  • GET /webhooks/{webhook_id} includes health for the last 24 hours: delivered, failed, dead, and pending counts, the last success and failure, consecutive failures, circuit_state, and effective_payload_detail.

Automatic deactivation

The platform deactivates a webhook endpoint when its deliveries keep failing: either 10 different deliveries of the endpoint fail within 24 hours, or its URL fails 30 times in a row. A server error, a connection failure, a timeout, a DNS server that does not answer, or a delivery that reaches the 72-hour limit while waiting counts toward these thresholds. A 4xx response, a 429, a Queue is full response, and a delivery that is only rescheduled do not. When the URL reaches 30 failures in a row, every endpoint that points to that URL is deactivated; endpoints with other URLs on the same host are not affected.

A deactivated endpoint shows status: inactive, its pending deliveries are canceled, and the administrators of the account are notified by email. PATCH /webhooks/{webhook_id} with status: active reactivates it: the circuit of its URL is reset, delivery resumes, and the deliveries from the last 48 hours that ended as dead or were canceled by the deactivation go back to the queue for a new attempt. Sending status: inactive to a deactivated endpoint does not change it.

Pausing an endpoint yourself with status: inactive is different: its pending deliveries wait instead of being canceled, and they resume when you set status back to active.

Retention and sandbox

Events are retained for 30 days. Webhook deliveries and their attempts are retained for at least 60 days after the delivery is created, and a delivery that has not reached a final status is kept. An event is removed only after its deliveries. In a sandbox account, deliveries to webhook endpoints registered in that sandbox are real, so you can use them as your test receiver, and every event generated in the sandbox is delivered to them, whatever its origin. See Sandbox.

On this page