Webhooks
Receive transfer updates as they happen instead of polling, and verify that each delivery came from Melo.
Webhooks push transfer updates to your server as a delivery progresses, so you do not have to poll GET /transfer/{transferId} on a timer.
Register an endpoint
Webhooks are configured per merchant in the Melo dashboard, not through the Public API. Each merchant has one active endpoint per environment: a live endpoint and, separately, a test endpoint (see Test mode).
When you register it you receive a signing secret. Store it alongside your API credentials — it is what proves an incoming request came from Melo.
The dashboard can send a test event to confirm your endpoint is reachable:
{
"event": "webhook.test",
"timestamp": "2026-01-01T10:00:00.000Z",
"livemode": true,
"data": {
"merchantId": "3f1b47bc-0000-4000-8000-000000000000",
"test": true,
"message": "This is a test webhook"
}
}A test event carries no transfer object. Ignore any event whose event is not one you handle.
When events fire
An event is sent every time a transfer moves through its fulfilment workflow — when it is created, paid, shipped, and delivered, and when a step fails.
Only deliveries created through the Public API produce webhooks. Deliveries your team creates in the dashboard do not.
There is currently one event type:
| Event | Meaning |
|---|---|
webhooks.transfer.status_changed | A step in the transfer's workflow changed. |
How a delivery arrives
Melo sends an HTTP POST with a JSON body:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | HMAC-SHA256 of the request body, hex-encoded |
X-Webhook-Event | Same value as event in the body |
X-Webhook-Timestamp | When Melo sent the request, ISO 8601 |
User-Agent | MeloWebhookBot |
Melo waits up to 10 seconds for a response. Return 2xx as soon as you have stored the event, and do the rest of your work asynchronously — a slow handler is indistinguishable from a broken one.
Retries
A delivery fails when your endpoint returns a non-2xx status, does not respond within 10 seconds, or cannot be reached. Melo then retries it with exponential backoff, up to 5 attempts in total:
| Attempt | Sent after the previous attempt fails |
|---|---|
| 1 | — (sent immediately) |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 4 minutes |
If the fifth attempt also fails, the event is dropped and not sent again. An endpoint that is down for more than about 8 minutes will miss events — reconcile with GET /transfer/{transferId} once it is back.
Delivery guarantees
| Property | Behaviour |
|---|---|
| Duplicates | Possible. Handle events idempotently, keyed on the transfer id and the step. |
| Ordering | Events for a single transfer are sent in the order they occurred, but a retried event can arrive after a later one. No ordering is guaranteed across different transfers. Use steps to work out the current state. |
| Gaps | Possible, for example when all retries fail. Treat steps as the authoritative history rather than assuming you saw every event. |
Verify the signature
Anyone can POST to your endpoint. Compute the HMAC yourself and compare it to X-Webhook-Signature before trusting the body.
The signature is HMAC-SHA256(raw request body, your signing secret), hex-encoded. Compute it over the raw body bytes exactly as received — parsing and re-serializing the JSON can change it.
const crypto = require('crypto');
const express = require('express');
const app = express();
// Keep the raw body; the signature is computed over the bytes, not the parsed object.
app.use('/webhooks/melo', express.raw({ type: 'application/json' }));
app.post('/webhooks/melo', (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.MELO_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const received = req.get('X-Webhook-Signature') ?? '';
// Constant-time compare, so the comparison cannot be timed to recover the signature.
const valid =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!valid) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
enqueueForProcessing(event); // acknowledge first, work afterwards
res.sendStatus(200);
});Reject unverified requests
Never act on a request whose signature does not match, and never fall back to trusting the body when the header is absent.
Payload
{
"event": "webhooks.transfer.status_changed",
"timestamp": "2026-01-01T10:05:00.000Z",
"livemode": true,
"data": {
"merchantId": "3f1b47bc-0000-4000-8000-000000000000",
"transfer": {
"id": "9c2f0a41-0000-4000-8000-000000000000",
"state": "inProgress",
"trackingCode": "Mx7k2p9",
"description": "Order #4821",
"source": "API",
"recipientInfo": {
"firstName": "Nino",
"lastName": "Beridze",
"phone": "+995555123456"
},
"totalPrice": "12.34",
"isFragile": false,
"isPerishable": false,
"warehouseId": "b7d1e620-0000-4000-8000-000000000000",
"destinationWarehouseId": null,
"pickupLocation": { "type": "Point", "coordinates": [44.7866, 41.7151] },
"pickupAddress": "Rustaveli Ave 1, Tbilisi",
"destinationLocation": { "type": "Point", "coordinates": [44.8015, 41.7225] },
"destinationAddressMetadata": {
"display_name": "Chavchavadze Ave 37, Tbilisi",
"lat": 41.7225,
"lng": 44.8015,
"city": "Tbilisi"
},
"pickupAddressMetaData": { "display_name": "Rustaveli Ave 1, Tbilisi" },
"deliveryOptionName": "STANDARD",
"steps": [
{ "step": "created", "order": 0, "status": "completed", "occuredAt": "2026-01-01T10:00:00.000Z" },
{ "step": "payed", "order": 1, "status": "completed", "occuredAt": "2026-01-01T10:00:01.000Z" },
{ "step": "shipped", "order": 2, "status": "completed", "occuredAt": "2026-01-01T10:05:00.000Z" },
{ "step": "delivered", "order": 3, "status": "pending", "occuredAt": "2026-01-01T10:00:01.000Z" }
]
}
}
}Envelope
| Field | Type | Description |
|---|---|---|
event | string | The event type. |
timestamp | string | When Melo built the event, ISO 8601. |
livemode | boolean | true for live transfers, false for test transfers. |
data.merchantId | string | The merchant the transfer belongs to. |
data.transfer | object | The transfer, as described below. |
data.transfer
| Field | Type | Description |
|---|---|---|
id | string | Transfer ID. Use this to correlate with your own order. |
state | string | inProgress, completed, or failed. |
trackingCode | string | Public tracking code shown to recipients. |
description | string | Whatever you sent when creating the transfer. |
source | string | Always API for webhook deliveries. |
recipientInfo | object | firstName, lastName, phone. |
totalPrice | string | Amount charged to your merchant balance. |
isFragile | boolean | Fragile handling flag. |
isPerishable | boolean | Perishable handling flag. |
warehouseId | string | null | Pickup warehouse. null when the transfer was picked up from a custom location. |
destinationWarehouseId | string | null | Destination warehouse. null when the transfer was delivered to an address. |
pickupLocation | object | GeoJSON Point, [lng, lat]. |
pickupAddress | string | Pickup street address. |
destinationLocation | object | GeoJSON Point, [lng, lat]. |
destinationAddressMetadata | object | Geocoded destination, including display_name. |
pickupAddressMetaData | object | Geocoded pickup address. |
deliveryOptionName | string | The product selected at creation, such as STANDARD. |
steps | array | The fulfilment workflow. See below. |
Fields can be absent
The six pickup and destination fields are omitted when a transfer has no delivery metadata. Treat every field as optional and key your logic on id and steps.
steps
Each transfer moves through four steps in a fixed order.
| Field | Type | Description |
|---|---|---|
step | string | created, payed, shipped, or delivered. |
order | number | Position in the workflow, 0 through 3. |
status | string | completed, pending, or failed. |
occuredAt | string | When the step reached its current status, ISO 8601. |
steps is the full history at the moment the event was sent, not just the step that changed. Compare it against what you have stored to work out what moved — this is also how you recover from a missed event.
A transfer is finished when delivered is completed. Any step reaching failed means the delivery ended unsuccessfully; state becomes failed too.
Test mode
Webhook endpoints have an environment: live (the default) or test. Register a test endpoint separately from your live one in the dashboard.
- A test transfer is delivered only to the test endpoint. If you have not registered one, test events are not delivered anywhere. They never go to the live endpoint.
- Live transfers go only to the live endpoint.
- Test events have
livemode: false. Headers, signing, and retries are the same as for live events. - The dashboard's test event also includes
livemode, matching real events.
Point your test endpoint at a staging URL so simulated events never reach a production system. If you do share one endpoint between environments, check livemode before acting on an event.
Recommended handling
- Verify the signature first, before parsing or logging anything.
- Acknowledge fast. Store the event, return
2xx, process asynchronously. - Be idempotent. Key on the transfer
idplus the step, so a repeat event is a no-op. - Reconcile. Poll
GET /transfer/{transferId}for anything still open — a low-frequency sweep is enough. - Do not trust the body alone. For anything that moves money or dispatches goods on your side, confirm against the API.