Webhooks
Receive real-time HTTPS notifications when messages are sent, delivered, or fail, or when OTP verifications are approved or expire. You can manage endpoints and inspect delivery logs either in the dashboard or via the app-scoped /api/v1/webhooks endpoints.
Event Catalog
Call GET /api/v1/webhooks/event-types to query the available events your application can subscribe to.
| Event ID | Description |
|---|---|
| sms:sent | Triggered when the mobile network operator confirms receipt of the SMS transmission (see SENT). |
| sms:delivered | Triggered when SMS delivery status updates to DELIVERED upon carrier handset confirmation. |
| sms:failed | Triggered when an outbound SMS fails delivery or expires (see FAILED and EXPIRED; tokens are automatically refunded). |
| verify:approved | Triggered when a user submits a valid OTP code via POST /verify/check. |
| verify:expired | Triggered when an OTP verification expires before approval. |
Payload & Headers
Every webhook delivery is an HTTP POST request with Content-Type: application/json and the following headers:
Content-Type: application/jsonUser-Agent: Emess-Webhook/1.0X-Signature-Timestamp: 1761472800X-Signature: t=1761472800,v1=5c9a8e3d...
Example Event Payloads
All webhook events share a consistent top-level envelope ({ id, event, timestamp, data }) with event-specific fields nested inside data:
// verify:approved{"id": "e1d2c3b4-5a6f-4789-8012-3456789abcde","event": "verify:approved","timestamp": "2026-10-25T12:11:20.000Z","data": {"appId": "app_1234567890","verificationId": "b8c4e1a2-7b2f-4d9a-9f12-3e4a5b6c7d8e","to": "0812345678","status": "APPROVED","verifiedAt": "2026-10-25T12:11:20.000Z"}}// ping (from POST /api/v1/webhooks/:id/test){"id": "f9e8d7c6-5b4a-4321-9876-543210fedcba","event": "ping","timestamp": "2026-10-25T12:00:00.000Z","data": {"appId": "app_1234567890","message": "This is a test webhook event from emess-api."}}
Custom JS Payload Wrapper (payload)
If your webhook receiver requires a custom body format (such as Slack incoming webhooks or third-party automation tools), you can configure a payloadWrapper JavaScript object expression on your endpoint. Before dispatching, Emess evaluates the expression with the full event envelope ({ id, event, timestamp, data }) available as the payload variable and signs the resulting JSON body.
// payloadWrapper expression:{ text: `[${payload.event}] Verification for ${payload.data.to} is ${payload.data.status}` }// Evaluated HTTP POST body sent to your endpoint:{"text": "[verify:approved] Verification for 0812345678 is APPROVED"}
Signature Verification
Each endpoint has a unique signing secret prefixed with whsec_. Emess signs the raw JSON request body using HMAC-SHA256:
- Parse the
X-Signatureheader (format:t=<unix_seconds>,v1=<hex_hmac>). - Construct the signed string:
t=${timestamp}.${rawBodyString}. - Compute
HMAC-SHA256(secret, signedString)as lowercase hex and compare withv1using a timing-safe equality check.
Node.js Example
import crypto from 'node:crypto'export function verifyEmessWebhook(rawBody: string, signatureHeader: string, secret: string): boolean {const parts = Object.fromEntries(signatureHeader.split(',').map((part) => part.split('=') as [string, string]))if (!parts.t || !parts.v1) return falseconst signedPayload = `t=${parts.t}.${rawBody}`const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')const a = Buffer.from(expected, 'hex')const b = Buffer.from(parts.v1, 'hex')return a.length === b.length && crypto.timingSafeEqual(a, b)}
PHP Example
<?phpfunction verifyEmessWebhook(string $rawBody, string $signatureHeader, string $secret): bool {$parts = [];foreach (explode(',', $signatureHeader) as $segment) {[$k, $v] = array_pad(explode('=', $segment, 2), 2, '');$parts[$k] = $v;}if (empty($parts['t']) || empty($parts['v1'])) {return false;}$signedPayload = 't=' . $parts['t'] . '.' . $rawBody;$expected = hash_hmac('sha256', $signedPayload, $secret);return hash_equals($expected, $parts['v1']);}
Delivery Statuses & Timeouts
- Timeout: Your endpoint must respond with a
2xxHTTP status code within 8 seconds. - Statuses: Each delivery record transitions from
PENDINGtoSUCCESS(2xx) orFAILED(non-2xx or network timeout). - Response Capture: Up to the first 1,000 characters of your server's response body are stored on the delivery record for debugging via
GET /api/v1/webhooks/:id/deliveries/:deliveryId.
Webhook Management API
All webhook endpoints are automatically scoped to the authenticated application (Bearer JWT). Target URLs registered via the API must use HTTPS. The full signing secret is returned only on POST /webhooks (201) and POST /webhooks/:id/rotate-secret (200); all other endpoints mask the secret (e.g. whsec_********a1b2).
| Endpoint | Description |
|---|---|
| GET /api/v1/webhooks/event-types | List subscribable event types ({ id, label, description }). |
| GET /api/v1/webhooks | List the app's webhook endpoints (secrets masked). |
| POST /api/v1/webhooks | Create an endpoint ({ url, description?, payloadWrapper?, events[], isActive? }). Returns 201 with the full secret. |
| GET /api/v1/webhooks/:id | Fetch a single endpoint (secret masked). |
| PATCH /api/v1/webhooks/:id | Partially update url, description, payloadWrapper, events, or isActive. |
| DELETE /api/v1/webhooks/:id | Delete the endpoint and cascade its deliveries. Returns 204 No Content. |
| POST /api/v1/webhooks/:id/rotate-secret | Generate a new signing secret and return it in full. |
| POST /api/v1/webhooks/:id/test | Send a test ping event and return { delivered, statusCode }. |
| GET /api/v1/webhooks/:id/deliveries | Cursor-paginated delivery log. Filter by status (PENDING|SUCCESS|FAILED), eventType, limit, cursor. |
| GET /api/v1/webhooks/:id/deliveries/:deliveryId | Inspect a single delivery including full payload and responseBody. |