API documentation
A reference for the Whapify public API: base URL, how to authenticate, the shape of an error, and the message, contact, group and campaign endpoints it exposes.
Base URL: https://api.whapify.id/api/v1
Every request and response is JSON. An unknown field in the body is rejected with invalid_json.
Authentication
The public API uses one API key per tenant, not a dashboard session. Create a key on the API Keys page in the dashboard, then send it on every request in the Authorization header.
Authorization: Bearer wh_live_••••The key is shown only once, when it is created. Store it somewhere safe; if it is lost or leaked, create a new one and revoke the old one from the dashboard.
A key is created with some of four permissions: send, read, contacts, campaigns. Leaving the field out grants all four. A key missing the permission an endpoint needs fails with forbidden, naming the one it lacks.
A key can be given an expiry when it is created, and can be revoked from the dashboard at any time. A revoked or expired key fails with the same unauthorized error as one that never existed, so a leaked key cannot be told apart from an unknown one. Revoking cannot be undone; the only way back is a new key.
Conventions
Ids are UUIDs. Timestamps use RFC3339 in UTC. Every response carries Cache-Control: no-store.
Idempotency
POST /wa/send accepts an Idempotency-Key header, kept for 24 hours. Sending the same key with the same body again returns the first response again, byte for byte, with an Idempotency-Replayed response header instead of queuing the message a second time.
Idempotency-Key: a-key-you-generate-once-per-send
Idempotency-Replayed: true # on a repeat with the same bodyThe same key with a different body answers 422 validation_failed instead of replaying. A duplicate that arrives while the first request is still being handled answers 409 idempotency_in_progress. A response describing something temporary, such as account_not_connected, quota_exceeded, a 5xx, or rate_limited, is not kept, so retrying once that clears is a fresh attempt rather than a replay.
Error codes
Every failure uses the same envelope: a short code and a message that is safe to show.
| Code | Status | Description |
|---|---|---|
validation_failed | 422 | One of the fields in the body failed validation. |
invalid_json | 400 | The body is not valid JSON, or it carries a field the server does not recognise. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
not_found | 404 | The record is not yours, or it does not exist. |
already_exists | 409 | A record with this value already exists. |
invalid_state | 409 | The campaign is not in a state that allows this action. |
account_not_connected | 409 | The WhatsApp account you are sending from is not connected. |
idempotency_in_progress | 409 | Another request with this Idempotency-Key is still being handled. |
quota_exceeded | 402 | The monthly send quota on your plan has been used up. |
media_too_large | 413 | The media file is over the 20 MB limit. |
storage_exceeded | 413 | The tenant's media storage is full. Delete some media first. |
unsafe_url | 422 | media_url has to be a public https link. |
invalid_signature | 401 | The signature on the media link does not match. |
link_expired | 410 | The media link has expired. Read the message again for a fresh one. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
Rate limits
Every /wa/* request is limited to 60 requests a minute per API key; going over it answers 429 rate_limited. A second limit of 600 requests a minute per IP address sits in front of authentication, and POST /wa/validate additionally shares a 6-requests-a-minute budget per tenant with the dashboard's own WhatsApp lookups, because every call there is a round trip to WhatsApp itself.
Response headers
| Code | Description |
|---|---|
X-RateLimit-Limit | The number of requests allowed in the current window. |
X-RateLimit-Remaining | The number of requests left in the current window. |
Retry-After | Seconds to wait before trying again, sent on a 429 response. |
These three headers are set on every authenticated answer. A 401 for a missing, revoked or expired key carries none of them.
Endpoints for sending a message, reading history, validating numbers, listing WhatsApp groups, managing contacts, running campaigns, and fetching media. All of them authenticate with an API key.
Send a message
POST /wa/sendAccepts one text, media or location message and queues it. The answer is 202: accepted, not yet sent.
| Field | Type | Required | Notes |
|---|---|---|---|
account | uuid | string | Required | Your account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890. |
account_id | uuid | Optional | Accepted as well, spelled as the dashboard's own field name for the same account. |
recipient | string | Required | The destination: an E.164 phone number without the plus sign, or a group id. |
type | text | image | video | audio | document | location | Required | text, image, video, audio, document, or location. |
body | string | Optional | The text body. Optional for a location message. |
media_id | uuid | Optional | A media id for a file already uploaded through the dashboard's media library. Use this or media_url, not both. |
media_url | string | Optional | A public https URL to the media. Used when you are not sending a media_id. |
location.lat | number | Optional | Latitude, as a number. |
location.lng | number | Optional | Longitude, as a number. |
location.name | string | Optional | A short place name shown with the pin. |
Example request
curl -X POST https://api.whapify.id/api/v1/wa/send \
-H "Authorization: Bearer wh_live_••••" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711" \
-d '{
"account": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"recipient": "6281234567890",
"type": "text",
"body": "Halo dari Whapify"
}'Success response
202
{
"id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
"account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"campaign_id": null,
"direction": "out",
"wa_message_id": null,
"chat_jid": "[email protected]",
"recipient": "6281234567890",
"group_name": null,
"type": "text",
"body": "Halo dari Whapify",
"media": null,
"location": null,
"status": "queued",
"error": null,
"scheduled_at": null,
"created_at": "2026-09-01T09:12:00Z",
"sent_at": null,
"updated_at": "2026-09-01T09:12:00Z"
}Errors
| Code | Status | Description |
|---|---|---|
validation_failed | 422 | One of the fields in the body failed validation. |
invalid_json | 400 | The body is not valid JSON, or it carries a field the server does not recognise. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
not_found | 404 | The record is not yours, or it does not exist. |
account_not_connected | 409 | The WhatsApp account you are sending from is not connected. |
idempotency_in_progress | 409 | Another request with this Idempotency-Key is still being handled. |
quota_exceeded | 402 | The monthly send quota on your plan has been used up. |
media_too_large | 413 | The media file is over the 20 MB limit. |
storage_exceeded | 413 | The tenant's media storage is full. Delete some media first. |
unsafe_url | 422 | media_url has to be a public https link. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
List messages
GET /wa/messagesReads the tenant's message history, newest first, with a keyset cursor.
| Field | Type | Required | Notes |
|---|---|---|---|
account | uuid | string (query) | Optional | Your account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890. |
account_id | uuid (query) | Optional | Accepted as well, spelled as the dashboard's own field name for the same account. |
direction | in | out (query) | Optional | in for received, out for sent. |
status | string (query) | Optional | Filters by status: queued, sending, sent, delivered, read, failed, or received. |
from | RFC3339 (query) | Optional | Only messages created at or after this time. |
to | RFC3339 (query) | Optional | Only messages created at or before this time. |
cursor | string (query) | Optional | The next_cursor value from the previous page. |
limit | number (query) | Optional | Page size, clamped to a server maximum. |
Example request
curl "https://api.whapify.id/api/v1/wa/messages?direction=out&status=delivered&limit=20" \
-H "Authorization: Bearer wh_live_••••"Success response
200
{
"messages": [
{
"id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
"account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"campaign_id": null,
"direction": "out",
"wa_message_id": null,
"chat_jid": "[email protected]",
"recipient": "6281234567890",
"group_name": null,
"type": "text",
"body": "Halo dari Whapify",
"media": null,
"location": null,
"status": "queued",
"error": null,
"scheduled_at": null,
"created_at": "2026-09-01T09:12:00Z",
"sent_at": null,
"updated_at": "2026-09-01T09:12:00Z"
},
{
"id": "8d7c6b5a-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
"account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"campaign_id": null,
"direction": "in",
"wa_message_id": "3EB0A1B2C3D4E5F60718",
"chat_jid": "[email protected]",
"recipient": "[email protected]",
"group_name": "Support Team",
"type": "text",
"body": "Sudah dikirim ya",
"media": null,
"location": null,
"status": "received",
"error": null,
"scheduled_at": null,
"created_at": "2026-09-01T09:12:00Z",
"sent_at": null,
"updated_at": "2026-09-01T09:12:00Z"
}
],
"next_cursor": "MjAyNi0wOS0wMVQwOToxMjowMFp8M2YyYTFiMGM"
}The second row above came from a group. The only thing that says so is chat_jid: when it ends in @g.us the chat is a group, and recipient holds the participant who wrote the message rather than the chat itself. group_name is the group directory's name for it, and is null both for a private chat and for a group that has never been read from the device. It is never filled in with the group id, because an id is not a name.
Errors
| Code | Status | Description |
|---|---|---|
validation_failed | 422 | One of the fields in the body failed validation. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
not_found | 404 | The record is not yours, or it does not exist. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
Validate numbers
POST /wa/validateChecks whether numbers are registered on WhatsApp. A number that cannot be read as a phone number is reported as not existing rather than failing the whole batch.
| Field | Type | Required | Notes |
|---|---|---|---|
account | uuid | string | Required | Your account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890. |
phones[] | string[] | Required | Up to 50 numbers, E.164 without the plus sign. A number that cannot be read as a phone number is reported as not existing rather than failing the whole batch. |
Example request
curl -X POST https://api.whapify.id/api/v1/wa/validate \
-H "Authorization: Bearer wh_live_••••" \
-H "Content-Type: application/json" \
-d '{
"account": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"phones": ["081200000123", "6281234567891", "0812-BAD-NUMBER"]
}'Success response
200
{
"results": [
{ "phone": "6281200000123", "exists": true, "jid": "[email protected]" },
{ "phone": "6281234567891", "exists": false, "jid": "" },
{ "phone": "0812-BAD-NUMBER", "exists": false, "jid": "" }
]
}Errors
| Code | Status | Description |
|---|---|---|
validation_failed | 422 | One of the fields in the body failed validation. |
invalid_json | 400 | The body is not valid JSON, or it carries a field the server does not recognise. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
not_found | 404 | The record is not yours, or it does not exist. |
account_not_connected | 409 | The WhatsApp account you are sending from is not connected. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
List groups
GET /wa/groupsLists the groups visible to an account.
| Field | Type | Required | Notes |
|---|---|---|---|
account | uuid | string (query) | Required | Your account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890. |
Example request
curl "https://api.whapify.id/api/v1/wa/groups?account=9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d" \
-H "Authorization: Bearer wh_live_••••"Success response
200
{
"groups": [
{
"id": "0c1b2a39-4859-4677-9685-9a4b3c2d1e0f",
"account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"gid": "[email protected]",
"name": "Support Team",
"participants": 12,
"cached_at": "2026-09-01T09:12:00Z"
}
]
}Errors
| Code | Status | Description |
|---|---|---|
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
not_found | 404 | The record is not yours, or it does not exist. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
Start a campaign
POST /wa/campaign/startCreates and starts a campaign in one call, to a list of recipients or a contact group. A campaign that fails to start is removed rather than left as an unwanted draft.
| Field | Type | Required | Notes |
|---|---|---|---|
account | uuid | string | Required | Your account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890. |
name | string | Required | A label for the campaign, or a name for the contact. |
recipients[] | string[] | Optional | An explicit list of destinations, phone numbers only. Use this or group_id, not both. |
group_id | uuid | Optional | Sends to every member of this contact group instead of a recipient list. This is a contact group's id, not a WhatsApp group's. |
message | string | Required | The text body. Optional for a location message. |
throttle | number (ms) | Optional | Milliseconds to wait between sends within the campaign, clamped to 500-60000. One number becomes both the minimum and the maximum of the pacing range. |
Example request
curl -X POST https://api.whapify.id/api/v1/wa/campaign/start \
-H "Authorization: Bearer wh_live_••••" \
-H "Content-Type: application/json" \
-d '{
"account": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"name": "Promo September",
"recipients": ["6281234567890", "6281234567891"],
"message": "Promo spesial bulan ini",
"throttle": 2000
}'Success response
202
{
"id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382104",
"account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"name": "Promo September",
"status": "running",
"type": "text",
"body": "Promo spesial bulan ini",
"media_id": null,
"throttle_min_ms": 2000,
"throttle_max_ms": 2000,
"total": 240,
"queued": 240,
"sent": 0,
"delivered": 0,
"failed": 0,
"started_at": "2026-09-01T09:12:00Z",
"finished_at": null,
"created_at": "2026-09-01T09:12:00Z",
"updated_at": "2026-09-01T09:12:00Z"
}A campaign takes phone numbers only. A group id is refused with validation_failed, so every phone in the items list is always a number. To send to a group, use POST /wa/send with the group id as the recipient.
Errors
| Code | Status | Description |
|---|---|---|
validation_failed | 422 | One of the fields in the body failed validation. |
invalid_json | 400 | The body is not valid JSON, or it carries a field the server does not recognise. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
not_found | 404 | The record is not yours, or it does not exist. |
invalid_state | 409 | The campaign is not in a state that allows this action. |
account_not_connected | 409 | The WhatsApp account you are sending from is not connected. |
quota_exceeded | 402 | The monthly send quota on your plan has been used up. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
Stop a campaign
POST /wa/campaign/stopStops a campaign that is running or paused. Stopping one that already finished or was already stopped answers invalid_state.
| Field | Type | Required | Notes |
|---|---|---|---|
id | uuid | Required | The campaign's id, from the start response. |
Example request
curl -X POST https://api.whapify.id/api/v1/wa/campaign/stop \
-H "Authorization: Bearer wh_live_••••" \
-H "Content-Type: application/json" \
-d '{
"id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382104"
}'Success response
200
{
"id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382104",
"account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
"name": "Promo September",
"status": "stopped",
"type": "text",
"body": "Promo spesial bulan ini",
"media_id": null,
"throttle_min_ms": 2000,
"throttle_max_ms": 2000,
"total": 240,
"queued": 0,
"sent": 187,
"delivered": 0,
"failed": 53,
"started_at": "2026-09-01T09:12:00Z",
"finished_at": "2026-09-01T09:44:12Z",
"created_at": "2026-09-01T09:12:00Z",
"updated_at": "2026-09-01T09:12:00Z"
}Errors
| Code | Status | Description |
|---|---|---|
invalid_json | 400 | The body is not valid JSON, or it carries a field the server does not recognise. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
not_found | 404 | The record is not yours, or it does not exist. |
invalid_state | 409 | The campaign is not in a state that allows this action. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
List contacts
GET /wa/contactsReads the tenant's contacts, filterable by name, phone or contact group, with a keyset cursor.
| Field | Type | Required | Notes |
|---|---|---|---|
search | string (query) | Optional | Filter by name or phone, matching anywhere in the value. |
group_id | uuid (query) | Optional | Only contacts belonging to this contact group. |
cursor | string (query) | Optional | The next_cursor value from the previous page. |
limit | number (query) | Optional | Page size, clamped to a server maximum. |
Example request
curl "https://api.whapify.id/api/v1/wa/contacts?search=budi&limit=20" \
-H "Authorization: Bearer wh_live_••••"Success response
200
{
"contacts": [
{
"id": "c3530767-8f0a-4b88-8df4-b07d423f9f7a",
"name": "Budi",
"phone": "6281234567890",
"tags": [],
"notes": null,
"groups": [],
"created_at": "2026-09-01T09:12:00Z",
"updated_at": "2026-09-01T09:12:00Z"
}
],
"next_cursor": ""
}Errors
| Code | Status | Description |
|---|---|---|
validation_failed | 422 | One of the fields in the body failed validation. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
Create a contact
POST /wa/contactsAdds one contact. A phone number the tenant already has fails with already_exists.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Required | A label for the campaign, or a name for the contact. |
phone | string | Required | An Indonesian WhatsApp number in any common format. Stored normalised to digits only, starting with 62. |
tags[] | string[] | Optional | Free-form labels for the contact. |
notes | string | Optional | Free-form notes about the contact. |
Example request
curl -X POST https://api.whapify.id/api/v1/wa/contacts \
-H "Authorization: Bearer wh_live_••••" \
-H "Content-Type: application/json" \
-d '{
"name": "Budi",
"phone": "0812-3456-7890",
"tags": ["vip"]
}'Success response
201
{
"id": "c3530767-8f0a-4b88-8df4-b07d423f9f7a",
"name": "Budi",
"phone": "6281234567890",
"tags": ["vip"],
"notes": null,
"groups": [],
"created_at": "2026-09-01T09:12:00Z",
"updated_at": "2026-09-01T09:12:00Z"
}Errors
| Code | Status | Description |
|---|---|---|
validation_failed | 422 | One of the fields in the body failed validation. |
invalid_json | 400 | The body is not valid JSON, or it carries a field the server does not recognise. |
unauthorized | 401 | The Authorization header is missing, or the API key is unknown, revoked or expired. |
forbidden | 403 | The API key does not have the permission this endpoint needs. |
account_suspended | 403 | The tenant that owns this API key is suspended. |
already_exists | 409 | A record with this value already exists. |
rate_limited | 429 | Too many requests from this client in one minute. |
internal | 500 | Something failed on the server. Try again. |
Fetch media
GET /media/{id}Reads the bytes of a message's attachment through a signed link. It takes no Authorization header.
| Field | Type | Required | Notes |
|---|---|---|---|
exp | number (query) | Required | The link's expiry, from a message's media block. |
sig | string (query) | Required | The link's signature, from a message's media block. |
Example request
curl "https://api.whapify.id/api/v1/media/7c6b5a49-3821-4d0e-9f1a-2b3c4d5e6f70?exp=1798000325&sig=5f2b1c9e" \
--output katalog.pngSuccess response
200
Content-Type: image/png
Content-Disposition: inline; filename="7c6b5a49-3821-4d0e-9f1a-2b3c4d5e6f70.png"
X-Content-Type-Options: nosniff
Cache-Control: no-storeErrors
| Code | Status | Description |
|---|---|---|
not_found | 404 | The record is not yours, or it does not exist. |
invalid_signature | 401 | The signature on the media link does not match. |
link_expired | 410 | The media link has expired. Read the message again for a fresh one. |
internal | 500 | Something failed on the server. Try again. |
POST /wa/send in Node and PHP
The same request as the curl example above, in two more languages.
const res = await fetch("https://api.whapify.id/api/v1/wa/send", {
method: "POST",
headers: {
Authorization: "Bearer wh_live_••••",
"Content-Type": "application/json",
},
body: JSON.stringify({
account: "6281234500000",
recipient: "6281234567890",
type: "text",
body: "Halo dari Whapify",
}),
});
console.log(await res.json()); // 202: { id, status: "queued", ... }Webhooks
Register a URL per event type from the dashboard's Webhooks page. Whapify posts the envelope below to it whenever a subscribed event happens, signed with the secret shown once when the webhook is created.
Events
message.received: A WhatsApp message arrived on a linked account.message.status: A sent message changed status: sent, delivered, read, or failed.account.status: A linked account's connection status changed.campaign.progress: A running campaign's counts changed.
Envelope
Every delivery is a POST with this body:
{
"id": "evt_01JA4C6E8G0K2M4P6R8T0V2X4Z",
"type": "message.status",
"created_at": "2026-09-01T09:12:05Z",
"data": {
"id": "msg_01J8X9Q7Z3K2N4P6R8T0V2W4Y6",
"status": "delivered"
}
}Headers
| Code | Description |
|---|---|
X-Signature | Hex HMAC-SHA256 of the raw body, keyed with the endpoint secret. |
X-Timestamp | When the delivery was sent, as a Unix timestamp in seconds. |
X-Event-Id | A stable id for this delivery. Use it to drop duplicates from a retry. |
A sample delivery
What Whapify sends to your endpoint, not what you send to Whapify.
curl -X POST https://your-app.example.com/webhooks/whapify \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=5f2b1c9e8a3d4f6b7c8e9a0d1f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b" \
-H "X-Timestamp: 1798000325" \
-H "X-Event-Id: evt_01JA4C6E8G0K2M4P6R8T0V2X4Z" \
-d '{ "id": "evt_01JA4C6E8G0K2M4P6R8T0V2X4Z", "type": "message.status", "created_at": "2026-09-01T09:12:05Z", "data": { "id": "msg_01J8X9Q7Z3K2N4P6R8T0V2W4Y6", "status": "delivered" } }'Retries
A delivery that does not answer with a 2xx status is retried up to 5 times, with a growing wait between attempts.
Verifying a delivery
Recompute the signature from the raw body and compare it to X-Signature in constant time, as in the sample below.
import { createHmac, timingSafeEqual } from "node:crypto";
// Every delivery carries the X-Signature header: "sha256=" plus the hex
// HMAC-SHA256 of the raw body, keyed with the endpoint secret.
export function verify(rawBody, signature, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature ?? "", "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}