Dashboard session

DLR Webhooks

Configure a delivery receipt webhook and verify signed message status events.

GET
/v1/webhooks/dlr
User session and approved production access - Read your configured DLR webhook.
PUT
/v1/webhooks/dlr
User session and approved production access - Create or update your DLR webhook endpoint.
POST
/v1/webhooks/dlr/rotate-secret
User session and approved production access - Rotate the webhook signing secret.
DELETE
/v1/webhooks/dlr
User session and approved production access - Delete your DLR webhook.

Overview

DLR webhooks let your application receive message status changes without polling GET /v1/messages/:message_id.

Bar9 sends a webhook when an SMS message moves through delivery states such as sent, delivered, or failed. Each request is signed with your webhook signing secret so your server can verify that the event came from Bar9 and was not modified in transit.

All configuration operations require an authenticated dashboard session and approved production access.

GET /v1/webhooks/dlr

Read the current configuration:

curl https://api.bar9.dev/v1/webhooks/dlr \
  --cookie "$BAR9_SESSION_COOKIE"

When no webhook is configured, the endpoint returns 200 OK with "data": null. Existing configurations return id, url, enabled, secret_preview, creation/update timestamps, and rotated_at. The full signing secret is never returned by this operation.

PUT /v1/webhooks/dlr

Open the dashboard, go to Webhooks, and enter your HTTPS endpoint URL. The URL must use https://; local and private network hosts are rejected.

When you create a webhook, Bar9 shows the signing secret once. Store it securely. Existing secrets are only shown as a preview. If you lose the secret, rotate it from the same dashboard page.

You can also configure the endpoint through the authenticated dashboard API:

curl https://api.bar9.dev/v1/webhooks/dlr \
  -H "Content-Type: application/json" \
  --cookie "$BAR9_SESSION_COOKIE" \
  -X PUT \
  -d '{
    "url": "https://example.com/webhooks/bar9/dlr",
    "enabled": true
  }'

The response is 200 OK. Creating a configuration returns signing_secret once. Updating an existing configuration keeps its current secret and returns only secret_preview.

{
	"ok": true,
	"data": {
		"id": "wh_...",
		"url": "https://example.com/webhooks/bar9/dlr",
		"enabled": true,
		"secret_preview": "whsec_••••••••a1b2",
		"signing_secret": "whsec_...",
		"created_at": 1778407200,
		"updated_at": 1778407200,
		"rotated_at": null
	}
}

POST /v1/webhooks/dlr/rotate-secret

Rotate a configured secret:

curl https://api.bar9.dev/v1/webhooks/dlr/rotate-secret \
  --cookie "$BAR9_SESSION_COOKIE" \
  -X POST

The new signing_secret is returned once and the previous secret stops validating new deliveries immediately. An account without a configured webhook receives 404.

DELETE /v1/webhooks/dlr

Delete the current configuration:

curl https://api.bar9.dev/v1/webhooks/dlr \
  --cookie "$BAR9_SESSION_COOKIE" \
  -X DELETE

Success returns 204 No Content. Deleting a missing configuration is idempotent.

Event request

Bar9 sends a POST request with JSON body and these headers:

Header Notes
X-Bar9-Event-ID Unique event id. Store it to make processing idempotent.
X-Bar9-Event-Type Event name, for example message.delivered.
X-Bar9-Timestamp Unix timestamp used in the signature.
X-Bar9-Signature HMAC-SHA256 signature with a v1= prefix.
Content-Type application/json.

Example payload:

{
	"id": "evt_...",
	"type": "message.delivered",
	"created_at": 1778407200,
	"data": {
		"id": "msg_...",
		"to": "+213****0000",
		"sender": "BAR9",
		"type": "message",
		"status": "delivered",
		"provider_message_id": "abc123",
		"segments": 1,
		"cost_credits": 5,
		"created_at": 1778407000,
		"queued_at": 1778407000,
		"sent_at": 1778407100,
		"delivered_at": 1778407200,
		"failed_at": null,
		"failure_reason": null
	}
}

Verify signatures

Build the signed payload as:

{X-Bar9-Timestamp}.{X-Bar9-Event-ID}.{raw request body}

Compute HMAC-SHA256 with your webhook signing secret and compare it to X-Bar9-Signature after removing the v1= prefix. Use a constant-time comparison and reject timestamps outside a short window such as five minutes.

import crypto from 'node:crypto';

function verifyBar9Webhook({ secret, timestamp, eventId, rawBody, signature }) {
	const value = signature.startsWith('v1=') ? signature.slice(3) : signature;
	const signedPayload = `${timestamp}.${eventId}.${rawBody}`;
	const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

	const expectedBytes = Buffer.from(expected, 'hex');
	const valueBytes = Buffer.from(value, 'hex');
	if (expectedBytes.length !== valueBytes.length) return false;

	return crypto.timingSafeEqual(expectedBytes, valueBytes);
}

Handle retries and duplicates

Return a 2xx status only after accepting the event. Store X-Bar9-Event-ID and ignore duplicates. Process webhooks asynchronously when possible, keep the endpoint fast, verify the signature before parsing business fields, and do not trust the event until verification succeeds.

Common errors

Status Typical cause
400 Invalid URL, non-HTTPS URL, or private/local destination.
401 Missing or invalid dashboard session.
403 Production access has not been approved.
404 Secret rotation requested before a webhook has been configured.