# Guide to Celitech Webhooks: Features and Best Practices

Celitech sells eSIM data packages through an API, so travel apps, telecom resellers and other businesses can offer mobile data to their own customers. Its webhooks report two kinds of change: a customer has used 50% or 80% of a package's data, or the eSIM itself has changed state on the customer's phone, from download and install through to enable, disable and delete. Teams use them to send top-up offers before data runs out and to track whether a customer actually installed what they bought.

## What are Celitech webhooks?

Webhooks aren't on by default. Celitech enables them per account when you ask, and the **Developers > Webhooks** page then lets you add endpoints and pick event types. Each delivery is a JSON `POST`.

Celitech's docs don't name a delivery platform, but the headers are `svix-id`, `svix-timestamp` and `svix-signature`, and the verification samples install the `svix` package. The signature is the Standard Webhooks HMAC-SHA256 construction that Svix uses.

## Celitech webhook features

| Feature | Details |
| --- | --- |
| Availability | Enabled per account on request |
| Configuration | Dashboard, **Developers > Webhooks** |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature`, with `svix-id` and `svix-timestamp` |
| Secret | One per endpoint, rotatable in the dashboard |
| Body | JSON |
| Event routing | Body field `type` |
| Response window | `2xx` within about 15 seconds |
| Retries | Increasing delays over a period of hours, then the endpoint may be disabled |
| Test events | Sent from the dashboard, never retried |
| Endpoints | Public HTTPS only |
| Source IPs | Not published |

## Events and payloads

| Documented event | Meaning |
| --- | --- |
| `consumption.threshold.reached` | Sent when an eSIM's data usage crosses 50% or 80% of its package's data limit. |
| `esim.downloaded` | Sent when an eSIM profile is downloaded to the user's device. |
| `esim.installed` | Sent when an eSIM is installed on the user's device. |
| `esim.enabled` | Sent when an eSIM is enabled on the user's device (turned on). |
| `esim.disabled` | Sent when an eSIM is disabled on the user's device (turned off). |
| `esim.deleted` | Sent when an eSIM is permanently deleted from the user's device. |

Bodies are flat, with no `data` wrapper. `consumption.threshold.reached` carries `purchaseId`, `dataLimitInGB`, `dataUsageRemainingInGB`, `actualPercentage` and `notificationPercentage`, the threshold that fired. The five `esim.*` events carry only `type` and the eSIM's `iccid`, so you look the eSIM up by ICCID to find the customer.

Celitech's API reference documents each body as an OpenAPI schema with per-field examples rather than a complete sample, and Hookdeck's samples library has none yet. Send an example event from the dashboard to see a real body before you write parsing code.

## Setting up Celitech webhooks

1. Ask Celitech to enable webhooks on your account if **Developers > Webhooks** isn't available.
2. In **Developers > Webhooks**, add an endpoint with an HTTPS URL and a description.
3. Select the event types you need and save. There is no verification challenge.
4. Copy the endpoint's signing secret and store it as `CELITECH_WEBHOOK_SECRET`.

Celitech's docs don't show the secret's format. Svix endpoint secrets start with `whsec_`, and the `svix` library, Hookdeck's source and the function below all base64-decode the part after that prefix. Check that your secret has it before you deploy.

To use Hookdeck, create a source with the **Celitech** type, paste the secret, and use the source URL as the endpoint URL. The [Hookdeck CLI](/docs/cli) forwards the source to your machine for testing:

```bash
npx hookdeck-cli listen 3000 celitech --path /webhooks/celitech

```

Dashboard example events are a single attempt. If one fails, send it again by hand.

## Securing Celitech webhooks

The signed content is `<svix-id>.<svix-timestamp>.<raw body>`, the key is the base64-decoded secret after `whsec_`, and the header can list several `v1,` signatures separated by spaces. The function below checks each one and uses `webhook-*` headers when `svix-id` isn't there:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Celitech's own example uses `new Webhook(secret).verify(...)` from the `svix` package, which also rejects a timestamp it considers "too far in the past or future". Celitech doesn't give a number. Svix's libraries use five minutes, and an event Hookdeck retries or replays keeps the original `svix-timestamp`, with no timestamp tolerance applied by Hookdeck. The function above skips the age check so those deliveries still verify.

For threshold events the useful work is a customer notification, which can wait for a worker:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// jobs.enqueue(name, payload) resolves once the job is stored.
function createCelitechReceiver(jobs) {
  const app = express();
  app.post('/webhooks/celitech', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.CELITECH_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    const deliveryId = req.headers['svix-id'] || req.headers['webhook-id'];
    const job = event.type === 'consumption.threshold.reached' ? 'offer-top-up' : 'sync-esim-state';
    try {
      await jobs.enqueue(job, { deliveryId, event });
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200); // well inside the 15-second window
  });
  return app;
}

```

When the request comes through a Hookdeck Celitech source, a bad signature doesn't produce a `401`. Hookdeck answers `200` and marks the request as failed verification, so it never reaches the receiver.

> **Make Celitech webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every eSIM and data-usage event, records each delivery, and holds events for your notification service while it is unavailable.

## Celitech webhook limitations and pain points

### Access depends on Celitech

**The Problem:** The webhook settings aren't there when you go to build the integration.

**Why It Happens:** Webhook access is enabled per account by Celitech, on request.

**Workarounds:**

* Ask for webhooks early in the project, and poll the usage API until they are on.

**How Hookdeck Can Help:** Once you have access, a Hookdeck source gives you a stable URL and a request log from the first delivery, which helps when you are seeing Celitech's bodies for the first time.

### The endpoint can be disabled

**The Problem:** After a long outage, Celitech stops sending events to your endpoint.

**Why It Happens:** Failed deliveries are retried with increasing delays "over a period of hours", and after that the endpoint may be disabled. Celitech doesn't publish the attempt count or intervals.

**Workarounds:**

* Check the endpoint's status in **Developers > Webhooks** after any outage.
* Reconcile usage through the API for purchases that should have crossed a threshold.

**How Hookdeck Can Help:** Hookdeck acknowledges Celitech straight away, so your downtime doesn't count against the endpoint, and holds the events until your service can take them.

### Thin eSIM events

**The Problem:** An `esim.deleted` event tells you which eSIM, but not which customer or purchase.

**Why It Happens:** The `esim.*` bodies carry only `type` and `iccid`.

**Workarounds:**

* Store the ICCID against the customer when you create the purchase, and look it up on each event.

**How Hookdeck Can Help:** A [transformation](/docs/transformations) can't add your customer data, but it can rename or reshape fields so every Celitech event reaches your service in the format it already expects.

### The secret format is undocumented

**The Problem:** You can't confirm from the docs how to use the secret.

**Why It Happens:** Celitech never prints a secret value. It is most likely a Svix `whsec_` secret, but the docs don't say.

**Workarounds:**

* Look at the secret in the dashboard. If it doesn't start with `whsec_`, contact Celitech before relying on any verifier.

**How Hookdeck Can Help:** Send a dashboard example event to a Hookdeck source and check the verification result in the request log before you go live.

## Best practices

### Deduplicate on `svix-id`

Every retry of a delivery carries the same `svix-id`. Store it before acting, so a retried threshold event doesn't send two top-up emails. There is more on this in [implementing webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use both thresholds

The 50% and 80% notifications arrive separately for the same `purchaseId`. Check `notificationPercentage` and send a gentle reminder at 50% and a stronger one at 80%, or act only on the one that fits your product.

### Track installation, not only purchase

`esim.installed` and `esim.enabled` tell you the customer actually set the eSIM up. Purchases with no install event after a day or two are a good trigger for a support message.

### Acknowledge quickly

Return `2xx` well inside the 15-second window and send notifications from a worker. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Celitech's six events track data running low and eSIM state changes on a device. Request access early, and keep the endpoint healthy so it isn't disabled after hours of failures.

[Hookdeck Event Gateway](https://hookdeck.com) gives Celitech a target that is always up and gives you a record of every eSIM event. [Try it free](https://dashboard.hookdeck.com/signup).