Skip to main content

Webhooks

Webhooks tell your system when an order, return, purchase order or brand health band changes, so you do not have to poll for it. Nasam sends an HTTPS POST to your endpoint with a small event that names what changed; you read the current state through the API.

Subscribe​

Create an endpoint with POST /webhook-endpoints:

curl -X POST 'https://api.nasam.co/mm-api/v1/webhook-endpoints' \
-H "Authorization: Bearer $NASAM_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://erp.example.com/nasam/webhooks",
"description": "ERP order sync",
"eventTypes": ["order.created", "order.cancelled"]
}'
{
"webhookEndpoint": {
"id": 3,
"url": "https://erp.example.com/nasam/webhooks",
"description": "ERP order sync",
"eventTypes": ["order.created", "order.cancelled"],
"status": "Enabled",
"disabledReason": null,
"createdAt": "2026-10-03T09:00:00Z",
"updatedAt": "2026-10-03T09:00:00Z"
},
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}

The secret is returned only in this response. Store it with your other secrets; you need it to verify every delivery.

  • The URL must be https, and its host must resolve only to public IP addresses. Redirects are not followed.
  • A user can have up to 10 endpoints. Each endpoint belongs to the user whose key created it, and only that user's keys can read or change it.
  • Send a test event with POST /webhook-endpoints/{id}/test. It delivers a webhook.test event once, with no retries, and returns the delivery record.
  • Update an endpoint to change its url, description, eventTypes or status. List endpoints, read one, or delete it.
  • List an endpoint's deliveries, newest first, to see each event's status, attempts and the HTTP status your endpoint returned.

Events​

TypeSent whendataRead next
order.createdAn order arrives for a brandbrandId, orderIdGet order
order.cancelledAn order is cancelledbrandId, orderIdGet order
order.returnedAn order is returnedbrandId, orderIdGet order
brand_health.band_enteredA brand health dimension enters a bandbrandId, dimension, bandGet brand health
brand_health.band_exitedA brand health dimension leaves a bandbrandId, dimension, bandGet brand health
webhook.testYou call the test operationwebhookEndpointId

Each event's page under Webhook events in the API reference has its full schema.

You receive an event only for brands in your scope, and only if you hold the read permission for it: orders.read for order.*, brand-health.read for brand_health.*. Scope is checked when the event happens, the same way it is for API requests.

New event types can be added within v1. Ignore a type you do not handle and return 2xx.

Payload​

POST /nasam/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Nasam-Webhooks/1
webhook-id: 0b8e6f5c-2d1a-4f7e-9a3b-5c6d7e8f9a0b
webhook-timestamp: 1759482764
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
"id": "0b8e6f5c-2d1a-4f7e-9a3b-5c6d7e8f9a0b",
"type": "order.cancelled",
"createdAt": "2026-10-03T09:12:44Z",
"data": { "brandId": 14, "orderId": 88123 }
}
FieldMeaning
idEvent ID. Equal to the webhook-id header and the same on every retry.
typeEvent type from the table above.
createdAtWhen the change happened. Use it to order events.
dataIDs of what changed. Read the resource through the API for its current state.

Verify the signature​

Nasam signs deliveries with the Standard Webhooks scheme. The signature is the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret after whsec_. webhook-signature holds one or more space-separated v1,{signature} values; accept the delivery if any of them matches.

Verify against the raw request body exactly as received. Parsing and re-serialising the JSON changes the bytes and the signature will not match. Reject a delivery whose timestamp is more than five minutes from your clock; that stops an old, captured request from being replayed. Every attempt is signed again with its own timestamp, so a retry passes this check.

Node.js​

import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

export function verifyNasamWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;

const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();

return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}

A receiver with Node's built-in http module, reading the raw body before parsing it:

import http from 'node:http';
import {verifyNasamWebhook} from './verify.mjs';

const seen = new Set(); // use your database in production

http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
if (!verifyNasamWebhook(rawBody, req.headers, process.env.NASAM_WEBHOOK_SECRET)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody);
if (!seen.has(event.id)) {
seen.add(event.id);
// queue event.type / event.data for a worker, then return quickly
}
res.writeHead(204).end();
});
}).listen(3000);

With Express, mount the route with express.raw({type: 'application/json'}) and pass req.body.toString('utf8') as the raw body.

Python​

import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_nasam_webhook(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False

try:
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
except ValueError:
return False

key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False

Requires Python 3.9 or later. In Flask pass request.get_data() and request.headers; in Django pass request.body and request.headers.

Check your implementation​

Both functions accept this test vector from the Standard Webhooks specification. Freeze your clock at the timestamp, or skip the timestamp check, when you run it:

InputValue
Secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
Body{"test": 2432232314}
Expected webhook-signaturev1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

Then send yourself a real event with the test operation.

Respond, retries and disabling​

Return any 2xx within 10 seconds. The response body is ignored. Verify, store the event, and return; do the work in a background job.

Any other status, a timeout or a connection error is retried. Each event gets 8 attempts: the first, then retries 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours after the previous attempt. After the last attempt the delivery is Failed. A delivery to an address that resolves to a private or reserved IP fails with no request sent.

If a delivery fails finally and the endpoint has had no successful delivery for 5 days, Nasam disables the endpoint: status becomes Disabled and disabledReason is FailingDeliveries. A disabled endpoint receives no events, and deliveries still pending for it are marked Failed. Fix your receiver, then update the endpoint with "status": "Enabled". Events from while it was disabled are not resent; read the API to catch up: list orders with a startDate that covers the gap for orders you have not seen, and re-read the open orders you track for status changes.

Duplicates and ordering​

  • Duplicates. A retry carries the same webhook-id. Your endpoint can also receive an event twice if it returned an error after storing it. Record each webhook-id you have processed and skip ones you have seen.
  • Ordering. Deliveries are not ordered. A retry of an earlier event can arrive after a later one. Order by createdAt, and since the event only names what changed, read the resource's current state rather than applying events in sequence.

Rotate the signing secret​

POST /webhook-endpoints/{id}/rotate-secret returns the endpoint with a new secret. For the next 24 hours every delivery carries two signatures, one from the new secret and one from the previous one, so the verification code above accepts deliveries while you deploy:

  1. Call rotate-secret and store the new secret.
  2. Deploy the new secret to your receiver within 24 hours.
  3. After 24 hours, deliveries are signed only with the new secret.

Rotate at once if a secret may have leaked. Rotation works on enabled and disabled endpoints.