MMelo Developers

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:

EventMeaning
webhooks.transfer.status_changedA step in the transfer's workflow changed.

How a delivery arrives

Melo sends an HTTP POST with a JSON body:

HeaderValue
Content-Typeapplication/json
X-Webhook-SignatureHMAC-SHA256 of the request body, hex-encoded
X-Webhook-EventSame value as event in the body
X-Webhook-TimestampWhen Melo sent the request, ISO 8601
User-AgentMeloWebhookBot

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:

AttemptSent after the previous attempt fails
1— (sent immediately)
230 seconds
31 minute
42 minutes
54 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

PropertyBehaviour
DuplicatesPossible. Handle events idempotently, keyed on the transfer id and the step.
OrderingEvents 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.
GapsPossible, 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

FieldTypeDescription
eventstringThe event type.
timestampstringWhen Melo built the event, ISO 8601.
livemodebooleantrue for live transfers, false for test transfers.
data.merchantIdstringThe merchant the transfer belongs to.
data.transferobjectThe transfer, as described below.

data.transfer

FieldTypeDescription
idstringTransfer ID. Use this to correlate with your own order.
statestringinProgress, completed, or failed.
trackingCodestringPublic tracking code shown to recipients.
descriptionstringWhatever you sent when creating the transfer.
sourcestringAlways API for webhook deliveries.
recipientInfoobjectfirstName, lastName, phone.
totalPricestringAmount charged to your merchant balance.
isFragilebooleanFragile handling flag.
isPerishablebooleanPerishable handling flag.
warehouseIdstring | nullPickup warehouse. null when the transfer was picked up from a custom location.
destinationWarehouseIdstring | nullDestination warehouse. null when the transfer was delivered to an address.
pickupLocationobjectGeoJSON Point, [lng, lat].
pickupAddressstringPickup street address.
destinationLocationobjectGeoJSON Point, [lng, lat].
destinationAddressMetadataobjectGeocoded destination, including display_name.
pickupAddressMetaDataobjectGeocoded pickup address.
deliveryOptionNamestringThe product selected at creation, such as STANDARD.
stepsarrayThe 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.

FieldTypeDescription
stepstringcreated, payed, shipped, or delivered.
ordernumberPosition in the workflow, 0 through 3.
statusstringcompleted, pending, or failed.
occuredAtstringWhen 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.

  • Verify the signature first, before parsing or logging anything.
  • Acknowledge fast. Store the event, return 2xx, process asynchronously.
  • Be idempotent. Key on the transfer id plus 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.

On this page