telnesstech

Webhooks

Event notifications for subscriptions, billing, payments, and usage, with at-least-once delivery.

A webhook tells you about an event in the Seamless OS platform as it happens, so you do not have to poll the API. Your endpoint gets an HTTPS POST directly after a subscription is created, a payment succeeds, or usage passes a threshold.

Enabling webhooks

You manage webhook delivery from Admin → Advanced → Webhooks in the Seamless OS portal. Your portal user needs the View webhooks permission to open that page. On the page you do four things:

  1. Add an endpoint. This is the HTTPS URL that gets the deliveries.
  2. Subscribe to event types from the catalog. The catalog lists every event that we send.
  3. Copy the signing secret of that endpoint. It starts with whsec_.
  4. Send a test event. The endpoint must answer before you depend on it.

Each endpoint has its own signing secret. The same page shows the delivery attempts, the response codes, and the payload of every message. You can examine a failed delivery and send it again without our help. An endpoint can also carry custom request headers. Use one for a static token when your gateway needs a token before your handler verifies the signature.

Webhook delivery is enabled per deployment. If the page is absent, write to us and we enable it for your environment.

Quick start

Every delivery carries a full snapshot of the resource in the same envelope. One event holds everything that you need to update your own systems.

Basic integration

  1. Configure your endpoint to accept an HTTPS POST request.
  2. Verify the signature over the raw request body, before you read the body.
  3. Parse the JSON payload and take the eventId for deduplication.
  4. Put the event on a durable queue. Do this before you answer.
  5. Answer HTTP 200 to acknowledge the delivery.
  6. Process the event out of band. A slow handler blocks the next delivery.

The verification pattern

import { Webhook } from 'svix';

const webhook = new Webhook(process.env.WEBHOOK_SIGNING_SECRET);

// The raw body is required: verification runs over the exact bytes we signed,
// so a JSON body parser on this route breaks it.
app.post('/webhooks/telness', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.status(400).send('Invalid signature');
  }

  const { eventId, type, data } = event;

  try {
    // Atomic lock acquisition to prevent duplicate processing
    const acquired = await redis.setnx(`webhook:${eventId}`, 'processing');
    if (!acquired) {
      return res.status(200).send('OK'); // Already processed
    }

    // Set expiration in case of crash
    await redis.expire(`webhook:${eventId}`, 3600);

    // Queue event for async processing
    await eventQueue.add('process-webhook', { eventId, type, data });

    // Mark as completed only after successful queuing
    await redis.set(`webhook:${eventId}`, 'completed', 'EX', 86400);

    res.status(200).send('OK');
  } catch (error) {
    // Clean up on failure to allow retry
    await redis.del(`webhook:${eventId}`);
    console.error('Webhook processing error:', error);
    res.status(500).send('Internal Server Error');
  }
});

Verifying signatures

Your webhook endpoint is a public HTTPS URL, so anyone who discovers it can post to it. Every delivery is signed, and verifying that signature is what tells you a request came from us rather than from someone who guessed the URL.

Treat an unverified payload as untrusted input. Without verification, an attacker who knows your endpoint can fabricate any event on this page, including payment and subscription state changes.

Use the official libraries

Deliveries are signed in the Svix format, which has maintained libraries for most languages. They handle the signature comparison, the timestamp check, and secret rotation for you:

npm install svix          # JavaScript / TypeScript
pip install svix          # Python
go get github.com/svix/svix-webhooks/go   # Go
composer require svix/svix                # PHP

Pass the raw request body and the request headers, and the library either returns the parsed event or throws:

import { Webhook } from 'svix';

const webhook = new Webhook(process.env.WEBHOOK_SIGNING_SECRET);
const event = webhook.verify(rawRequestBody, requestHeaders);

Signature headers

If you verify by hand, three headers carry what you need:

HeaderDescription
svix-idUnique message identifier, stable across retries of one delivery
svix-timestampDelivery timestamp, in seconds since the Unix epoch
svix-signatureSpace-delimited list of versioned signatures, such as v1,<base64>

The signature is an HMAC-SHA256 over {svix-id}.{svix-timestamp}.{rawBody}. The key is the part of your signing secret after the whsec_ prefix, base64-decoded. The result is encoded as base64. Compare it in constant time.

svix-signature can list more than one signature. During a secret rotation, the old secret and the new secret both sign each delivery. A verifier that reads the first entry only breaks in the middle of the rotation. Accept the delivery when one of the listed signatures matches.

Replay protection

Include svix-timestamp in the signed content, and reject deliveries whose timestamp is outside a tolerance you choose. Five minutes is a reasonable default. Without that check a signature stays valid forever, so a captured request can be replayed indefinitely. The official libraries enforce this by default.

The timestamp check does not replace eventId deduplication. A retry of a genuine failed delivery arrives with a new timestamp and a valid signature. Your handler must tolerate it.

Two things verification does not give you

  • It is not authentication of a user. A verified delivery proves the payload came from your Seamless OS deployment, nothing about who triggered it.
  • It is not a freshness guarantee for the resource. The payload is a snapshot from when the event occurred, and a retry can arrive hours later. Re-fetch through the API when you need current state.

Event structure

All webhook payloads use the same envelope format with complete resource snapshots:

{
  "eventId": "8d7e6c5b-4a3f-2e1d-9c0b-112233445566",
  "type": "subscription.activated",
  "occurredAt": "2025-09-30T12:34:56Z",
  "apiRevision": "2026-08-21.auk",
  "data": {
    "subscriptionId": "123e4567-e89b-12d3-a456-426614174000",
    "status": "ACTIVATED",
    "customer": {
      "customerId": "987f6543-21cb-a0ed-654f-987654321000",
      "name": "Acme Corporation",
    },
    "productOffering": {
      "productOfferingId": "456a789b-cd12-34ef-567g-890123456789",
      "name": "Seamless 10GB",
      "price": {
        "netPriceMinor": 2999,
        "currency": "USD",
        "priceType": "RECURRING",
        "billingCycle": { "period": "MONTHLY", "interval": 1 },
        "standardDiscount": { "amountMinor": 500 },
        "bindingContract": {
          "duration": { "unit": "MONTHS", "value": 12 },
          "discount": { "amountMinor": 200 },
        },
        "customUpfrontPayment": {
          "billingCycles": 3,
          "discount": { "amountMinor": 300, "duration": { "unit": "MONTHS", "value": 3 } },
        },
        "currencyOptionsMinor": { "USD": 2999, "SEK": 29900 },
      },
    },
    "subscriber": {
      "msisdn": "+46701234567",
      "email": "user@acme.com",
    },
    "createdAt": "2025-09-25T08:15:30Z",
    "updatedAt": "2025-09-30T12:34:56Z",
  },
}

Envelope fields

FieldDescription
eventIdUnique identifier for this logical event (stable across delivery retries)
typeDot-namespaced event identifier (domain.action)
occurredAtWhen the underlying business event happened
apiRevisionThe API revision that the payload is in
dataComplete snapshot of the affected resource at that moment

Payload revision

A webhook payload follows the same revisions as the API. A delivery is not a call, so it carries no API key and cannot take the pin of one. One setting decides the shape instead.

Open Admin > Advanced > Webhooks in the portal to read the setting and to move it. Change revision shows what each hop changes before you commit to it. Your portal user needs the Manage webhooks permission to move the setting.

Three rules apply to this setting:

  • It starts at the oldest supported revision, and we never move it for you.
  • It applies to every endpoint of your account at the same time.
  • The next delivery after the change carries the new shape.

Every delivery repeats the revision in the apiRevision field, so a stored payload always says which contract it follows. Each revision has an OpenAPI document that describes the webhook bodies as well as the endpoints.

Move the setting after your handler accepts the new shape, not before. A delivery in flight is not rewritten, and the platform sends no payload twice.

API integration

A webhook payload carries the same resource data as the API endpoints. The data object has the schema of the matching GET response, so one model covers your events and your API calls.

Example correlations:

  • subscription.created → GET /subscriptions/{subscriptionId}
  • paymentLink.expired → GET /payment-links/{paymentLinkId}
  • order.submitted → GET /orders/{orderId}

As a result, you can use the webhook data as it arrives. You can also get more detail from the API with the identifiers in the payload.

Delivery guarantees

Reliability. Delivery is at least once. A failed delivery is retried with exponential backoff for about one day.

Idempotency. Use the eventId field as your deduplication key. Every retry of one logical event carries the same eventId.

Ordering. Events on different resource types can arrive in any order. Events on one resource normally arrive in causal order. Your handler must be idempotent either way.

Payload format. The payload carries the full snapshot of the resource, where a snapshot applies.

When retries run out

A message that uses up its retries is marked failed, not dropped. You can retry it, or recover a whole batch, from Admin → Advanced → Webhooks. That is the path back after an outage longer than the retry window. An endpoint that fails continuously for days is disabled automatically. Examine the state of the endpoint before you read a quiet period as quiet traffic.

Implementation guide

  1. Parse the JSON payload and validate its structure.
  2. Find duplicates with eventId, before you do anything else.
  3. Put the event on a durable queue. Your business logic then runs outside the handler.
  4. Acknowledge with HTTP 200, after the queue accepted the event and not before.
  5. Do the side effects out of band: the database writes and the notifications.

Idempotency best practices

  • Use eventId as your deduplication key. It is stable across every retry.
  • Look for the key first. Always find out whether you handled the event already.
  • Mark the event processed only after it succeeds. Otherwise a failure drops it.
  • Put a TTL on every lock. A crash or a timeout then cannot leave a lock behind.
  • Erase the lock after a failure, so the retry can take it.

For more idempotency patterns, read the Idempotency guide.

Error handling

We read the HTTP status code only. We ignore the response body. Answer with a 2xx status code after the event is queued or processed, and not before. Any other status code starts a retry with exponential backoff.

Best practices:

  • Answer 200 for an event that you processed, and for a duplicate.
  • Answer 4xx for a malformed payload. This stops the retries.
  • Answer 5xx for a temporary fault. This starts a retry.
  • Put nothing in the response body. We do not read it.

Troubleshooting

Missing events. Make sure that your endpoint answers HTTP 200, and that it answers in less than 10 seconds.

Duplicate processing. Look up the eventId before every operation that is not idempotent.

Event ordering. Build your handler for events in any order. Do not depend on chronological delivery.

Large payloads. An event carries the full snapshot of its resource. That snapshot is large for a complex order or subscription.

Anything missing?

We add to the platform continuously. If you want a webhook event or a feature that is not here, write to us.