V1
API v1•Webhooks

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 IDDescription
sms:sentTriggered when the mobile network operator confirms receipt of the SMS transmission (see SENT).
sms:deliveredTriggered when SMS delivery status updates to DELIVERED upon carrier handset confirmation.
sms:failedTriggered when an outbound SMS fails delivery or expires (see FAILED and EXPIRED; tokens are automatically refunded).
verify:approvedTriggered when a user submits a valid OTP code via POST /verify/check.
verify:expiredTriggered 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:

Webhook Headers
Content-Type: application/json
User-Agent: Emess-Webhook/1.0
X-Signature-Timestamp: 1761472800
X-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:

Webhook Payloads
// 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.

JS Wrapper Expression Example
// 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:

  1. Parse the X-Signature header (format: t=<unix_seconds>,v1=<hex_hmac>).
  2. Construct the signed string: t=${timestamp}.${rawBodyString}.
  3. Compute HMAC-SHA256(secret, signedString) as lowercase hex and compare with v1 using a timing-safe equality check.

Node.js Example

Node.js / TypeScript
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 false
const 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

PHP
<?php
function 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 2xx HTTP status code within 8 seconds.
  • Statuses: Each delivery record transitions from PENDING to SUCCESS (2xx) or FAILED (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).

EndpointDescription
GET /api/v1/webhooks/event-typesList subscribable event types ({ id, label, description }).
GET /api/v1/webhooksList the app's webhook endpoints (secrets masked).
POST /api/v1/webhooksCreate an endpoint ({ url, description?, payloadWrapper?, events[], isActive? }). Returns 201 with the full secret.
GET /api/v1/webhooks/:idFetch a single endpoint (secret masked).
PATCH /api/v1/webhooks/:idPartially update url, description, payloadWrapper, events, or isActive.
DELETE /api/v1/webhooks/:idDelete the endpoint and cascade its deliveries. Returns 204 No Content.
POST /api/v1/webhooks/:id/rotate-secretGenerate a new signing secret and return it in full.
POST /api/v1/webhooks/:id/testSend a test ping event and return { delivered, statusCode }.
GET /api/v1/webhooks/:id/deliveriesCursor-paginated delivery log. Filter by status (PENDING|SUCCESS|FAILED), eventType, limit, cursor.
GET /api/v1/webhooks/:id/deliveries/:deliveryIdInspect a single delivery including full payload and responseBody.