Skip to main content
Exode webhooks are outgoing HTTP notifications: when an event occurs on the platform (sign-up, payment, course progress, etc.), Exode sends a POST request with the event data to your URL.
Delivery is near real-time through a queue with retries. Your receiver must be idempotent and respond quickly with 200, 201 or 202 (response timeout: 15 seconds).

How it works

1

The event is recorded

The Exode core generates a domain event (for example, PaymentCompleted) with a timestamp and the initiator parameters.
2

Active endpoints are looked up

Exode finds your seller’s active endpoints subscribed to this event. You can create no more than 5 endpoints per seller.
3

The payload is assembled

The related entities (user, payment, access, etc.) are collected for the event, and data, a single idempotencyKey and timestamp are built. Extra fields are stripped according to a strict schema (allow-list).
4

Signed delivery and retries

The body is sent as a POST request with a signature header (HMAC-SHA256). If the receiver does not respond with 200/201/202, delivery is retried with an increasing delay, up to 5 attempts in total.
To stop delivery, set the endpoint to active = false or delete it.

Message format

The request body is a JSON object with the following structure:
string
required
Event name. One of the values listed in Supported events.
string
required
The moment the event occurred, in ISO 8601 format (UTC).
string
required
A single event key. The same key is used for all endpoints and is preserved across retries. Use it for deduplication.
object
required
Payload with domain entities. Its contents depend on the event; see below.
Body structure
The signature header is literally named signature (lowercase), not X-Signature. The signature is computed over the entire request body, including event, timestamp and idempotencyKey.

Signature verification

Every request carries a signature header: an HMAC-SHA256 (hex) of the raw request body, signed with your endpoint’s secret key.
secretKey is passed to HMAC as is, as a 64-character ASCII/UTF-8 string. Despite its appearance, you do not need to decode it from hex or base64. The resulting signature is a 64-character lowercase hex string with no sha256= prefix.
Compute the HMAC over the raw request body. Re-serializing the JSON (with a different field order, whitespace or Unicode escaping) produces a different signature.

Delivery and retries

Any response other than 200/201/202, as well as a timeout or network error, counts as a failure and triggers a retry. After 5 failed attempts the event is marked as failed and is no longer sent.
Endpoint auto-disable. If an endpoint does not accept events for 14 consecutive days (no successful delivery during that time and it was not re-enabled manually), Exode sets it to active = false and notifies the seller’s owner. No new events are sent to such an endpoint until it is re-enabled in the account.

Managing endpoints

Webhook endpoints are configured in the account: Manage → School → Webhooks (/manage/school/webhooks). You need the School Settings Management permission (SchoolManageSettings). Endpoints cannot be managed through the SaaS API. For each endpoint you set:
  • url — the receiver address (up to 255 characters); use HTTPS;
  • events — the list of events it is subscribed to;
  • active — the activity flag (true by default);
  • note — a free-form note (up to 255 characters);
  • secretKey — the secret for signature verification, generated automatically when the endpoint is created.
Where to get the secret for signature verification: secretKey is created together with the endpoint. In the account you copy it with the “Copy signature” command, available in the endpoint row menu or next to the URL field in its form (despite the name, it copies the secret itself, not a ready-made signature). Store it in your service’s environment variables (EXODE_WEBHOOK_SECRET in the examples above). If the secret is not displayed, request it from support.
Use separate endpoints for independent systems: this isolates failures and lets you manage different sets of events. Each seller can have at most 5 endpoints.

Test delivery

A test delivery from the form of a saved endpoint is signed with that endpoint’s own secretKey. The body format, algorithm and header are exactly the same as for production events, so your receiver does not need a separate verification branch for test webhooks.
A new endpoint that has not been saved yet does not have a permanent secretKey. Its test delivery is signed with a temporary key, so you cannot verify that signature with the key that appears after creation. To test the full verification cycle, save the endpoint first, then send a test from its form.

Supported events

Below are the events that are actually delivered, along with the structure of their data field. Nested entities (user, profile, course, payment, etc.) are described in the object reference.
Trigger: the user completed sign-up on their own.data:
  • user — the user object.
  • profile — the profile card (null if not filled in).
  • states.utmSignupParams — an object with the UTM tags of the first visit (optional).
Trigger: the user completed onboarding. The data structure is identical to UserSignedUp (user, profile, states.utmSignupParams).
Trigger: the user linked a Telegram account.data:
  • user — the user object (with the current tgId).
  • profile — the profile card (optional).
  • prevTgId — the previous tgId, for tracking re-linking (number | null).
Trigger: a school employee created a new user — in the admin panel (one by one or via bulk import) or through the API user/create and user/upsert (only when upsert creates the user rather than updating an existing one). A user who signs up on their own arrives as a separate UserSignedUp event.data:
  • user — the user object.
  • profile — the profile card (optional).
Trigger: the status of a lesson changed for the user.data:
  • user — the user with profile.
  • course — the course object.
  • product — the course product (optional).
  • access — the user’s access to the course product (optional).
  • states.utmEnrollParams, states.utmSignupParams — enrollment and sign-up UTM tags; see UTM attribution.
  • groups — an array of the user’s groups in the course (optional).
  • status — the new lesson status from the progress record (optional): NotStarted, OnTheory, OnPractice, OnReview, OnCorrection or Completed; see courseProgress for details.
  • lessonId — the lesson ID (optional).
Trigger: the user completed the course.data:
  • user — the user with profile.
  • course — the course object.
  • product — the course product (optional).
  • access — the user’s access (optional).
  • states.utmEnrollParams, states.utmSignupParams — enrollment and sign-up UTM tags; see UTM attribution.
  • groups — an array of groups (optional).
Trigger: the user completed a lesson’s practical assignment.data:
  • user — the user with profile.
  • course — the course (optional).
  • lesson — the lesson (optional).
  • practice — the practice parameters (optional).
  • attempt — the attempt, with score and status (optional).
  • variantId — the variant ID (optional).
Trigger: a payment completed successfully.data.payment — the payment object with the full tree: invoice (the invoice), invoice.user (the buyer with profile and school), invoice.products (line items with product/course, price and discount), acquiring (the acquiring and its provider, without secrets). Monetary fields are numbers.The event is sent only when money is actually charged: initializing a recurring payment (card binding, a zero payment with the BindingCompleted status) does not trigger the webhook.
Trigger: the user was granted free access to a product.data:
  • user — the user.
  • profile — the profile card (optional).
  • access — the access object (optional).
  • states.utmEnrollParams, states.utmSignupParams — enrollment and sign-up UTM tags; see UTM attribution.
  • product — the product (optional).
  • course — the course (optional).
Trigger: product access was granted by an administrator/manager through the LMS or the API. The data structure is identical to ProductEnrolledToFree (user, profile, access, product, course, states).
Trigger: product access was granted after a successful payment. The data structure is identical to ProductEnrolledToFree (user, profile, access, product, course, states).
Trigger: the user was issued a certificate for completing a course. The event is sent once per “user + course” pair: completing the course again does not create a new certificate.data:
  • user — the user with profile.
  • course — the course object.
  • product — the course product (optional).
  • certificate — the certificate object with a public link (opens without authorization), a validity period and a snapshot of the data at the time of issue.
You can subscribe to UserSignedIn, UserLoggedOut, UserJoinedByReferral, CourseLessonPracticeDetailedSent, CourseLessonPracticeAutoVerifySent, ProductRefundCompleted, ProductAccessSubscriptionEnding7Days and ProductAccessSubscriptionEnding1Day, but the formal data contract for them is not fixed yet, so the payload contents are not guaranteed. Check with support before using them.

UTM attribution

UTM tags arrive in three places, depending on the event: UTM object keys: utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid, yclid, referrer, aff_id, sub_id, track_id (only the ones that were passed are present). If the user arrived without tags, the object is absent or empty.
The access.metaHistoryLogs[0].meta.utmParams path used by early integrations is no longer returned in the contract. Switch your scenarios to states.utmEnrollParams or access.meta.utmParams.

Example: PaymentCompleted

The body below is a real test-delivery example (synthetic values, exact field set):

Troubleshooting

Every attempt uses the same payload and idempotencyKey. Delays are counted from the previous failed attempt (≈ 11 / 33 / 77 / 165 minutes). Return a success code to stop the retries.
Set active = false to pause delivery without losing the configuration and the secret key. Retries of events that were queued before disabling stop after the next failed attempt.
Log event, timestamp, idempotencyKey and the signature verification result: this speeds up reconciliation with our side when investigating incidents.

Updated: 2026-09-28 05:04 UTC