Skip to main content
By the end of this guide, you’ll have a working webhook endpoint that receives and verifies Mesh transfer status events — so you can reliably know when a transfer succeeds, fails, or is pending.
Before you start
  • You have a Mesh account with a sandbox API key (see Prepare to build)
  • You have a server with an endpoint that can receive POST requests from the public internet

Overview

If your business relies on transfer status to make business decisions (releasing inventory, allocating funds, etc.), then polling Mesh’s managed transfers history endpoint (/api/v1/transfers/managed/mesh) is an inefficient and ineffective solution. Mesh offers webhooks to solve this problem. A webhook is a callback function that allows lightweight, event-driven communication between two systems. In this case, the events are updates to transfer statuses. For an overview of how Mesh uses webhooks and how they differ from the onTransferFinished SDK callback, see Concepts.

Listen for Mesh webhook events

1. Create and register your callback URI

  • Create an endpoint that can receive a POST request with application/json content.
  • In the Mesh developer dashboard, go to Account > API keys > Webhooks.
  • Register a Callback URI for the appropriate environment.
Image
Notes:
  • When registering an endpoint, you’ll be prompted to store your secret key, as you won’t be able to view it again.
  • You can only save one production URI and one sandbox URI, but you can deactivate one and save a new one at any time.

2. Whitelist Mesh’s IP

  • All webhook events will come from this static IP: 20.22.113.37

3. Verify the signature

Mesh uses HMAC-SHA256 (Hash-based Message Authentication Code) to sign each webhook event. When you register your Webhook URI, you receive a secret key from Mesh. Mesh uses this key to sign each outgoing request and includes the result in the X-Mesh-Signature-256 header. You verify the signature on your end using the same secret to confirm the payload is authentic and hasn’t been tampered with. Compute an HMAC-SHA256 over the raw request body using your webhook secret, Base64-encode the result, and compare it to the value in the X-Mesh-Signature-256 header.
Use the raw request body bytes — not parsed/re-serialized JSON. Parsing the body first then re-serializing it before computing the HMAC silently changes the byte sequence: JSON parsers can reorder keys, normalize decimal precision, or strip whitespace — producing a different result than what Mesh signed. In Node.js: use express.raw() instead of express.json().
public string GenerateHmacSignature(string payload, string webhookSecret)
{
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret));
    byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
    return Convert.ToBase64String(hash);
}
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const rawBody = req.body;
  const receivedSig = req.headers['x-mesh-signature-256'];
  const computedSig = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('base64');
  if (computedSig !== receivedSig) return res.status(400).send('Invalid signature');
  res.sendStatus(200);
});
raw_body = request.get_data()
received_sig = request.headers.get("X-Mesh-Signature-256")
computed_sig = base64.b64encode(
    hmac.new(WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).digest()
).decode()
if computed_sig != received_sig:
    return "Invalid signature", 400
Critical: always use the raw request body. The HMAC is computed over the exact byte sequence of the HTTP request body — as received, before any parsing. A common mistake is parsing the JSON first and then re-serializing it before computing the HMAC. This silently changes the byte sequence: JSON parsers normalize decimal precision, reorder keys, or strip whitespace — producing a different result than what Mesh signed, and your verification will fail. Never:
  • Parse and re-serialize the JSON body before computing the HMAC
  • Round or normalize decimal values (SourceAmount, DestinationAmount, etc.)
  • Use express.json() middleware in Node.js without separately capturing the raw body (use express.raw() instead)
Debugging tip: If your signatures aren’t matching, log these three things side-by-side: the raw request body as a string, the X-Mesh-Signature-256 header value, and your locally computed HMAC. Comparing them directly will quickly surface where the divergence is.

4. Respond to Mesh webhook events

  • Respond with a 200 response in < 200ms to confirm receipt of the event.
  • Process the event asynchronously. Return 200 immediately, then handle your business logic in a background process. If your handler does work before returning 200, you risk exceeding the 200ms timeout.
  • Mesh does not guarantee exactly-once delivery. Webhooks may be retried and delivered more than once — for example, if your server times out or returns a non-200 response. Design your handler to be idempotent: retries carry the same EventId but a new Id — use EventId as your idempotency key to safely deduplicate.

Webhook Event Model

The webhook payload contains the core information related to a transfer update, and also includes additional fields specific to the webhook event.

Webhook call data

EventId Guid
A unique identifier for the event. Stays the same across all retries — use this as your idempotency key to deduplicate.
Id Guid
A unique identifier for each individual webhook delivery attempt (SentID). A new Id is generated on every retry for the same event.
SentTimestamp long
Unix timestamp (seconds) indicating when the webhook was dispatched.

Transfer data

TransferId Guid
Mesh’s unique identifier for the transfer.
Timestamp long
Unix timestamp (seconds) indicating when the transfer event occurred.
TransferStatus string
Transfer status at the time of this event. See Transfer Status Values below.
TransactionId string
The transactionId you provided in the Link Token request. Use this to match webhook events to transactions in your own system.
TxHash string
The blockchain transaction hash. Present on succeeded events; absent on failed.
UserId string
The userId you provided in the Link Token request.
Token string
Symbol of the transferred token (e.g. USDC, ETH).
Chain string
Name of the network the transfer occurred on (e.g. Ethereum, Solana).
SourceAmount decimal?
Token amount that left the source account. Always compute your HMAC over the raw bytes of this value — never parse/re-serialize the decimal, as precision changes will break signature verification.
SourceAccountProvider string
Name of the integration used to send the token (e.g. Binance, MetaMask).
DestinationAmount decimal?
Token amount received at the destination (after fees). May differ from SourceAmount if bridging or conversion occurred.
DestinationAddress string
The destination wallet address where funds were sent.
RefundAddress string
The address funds can be returned to. Save this for refund flows and compliance. See Add Mesh to your withdrawal flow.

Transfer Status Values

StatusDescription
pendingThe transfer has been initiated via Mesh, but has not yet reached a final state. Mesh does not yet have a Transfer Hash for this transfer.
succeededA final state that indicates the transfer was successfully delivered to the destination address. Mesh has a Transfer Hash for this transfer.
failedA final state that indicates the transfer has failed. No transfer hash available.

Payload

The payload format is JSON. Here is an example.
{
  "Id": "358c6ab7-4518-416b-9266-c680fda3a8dd",
  "EventId": "56713e70-be74-4a37-0036-08da97f5941a",
  "SentTimestamp": 1720532648,
  "UserId": "user_id_provided_by_client",
  "TransactionId": "transaction_id_provided_by_client",
  "TransferId": "dd4063e5-f317-441c-3f07-08dc7353b6f8",
  "TransferStatus": "Pending",
  "TxHash": "0x7d4ec1ce50952a377452c95fdf5a787ff551f08c0343093f866c84f57c473495",
  "Chain":"Ethereum",
  "Token":"ETH",
  "DestinationAddress":"0x0Ff0000f0A0f0000F0F000000000ffFf00f0F0f0",
  "SourceAccountProvider" :"Binance",
  "SourceAmount":0.004786046226555188,
  "DestinationAmount":0.004786046226555188,
  "RefundAddress": "0x0Ff0000f0A0f0000F0F000000000ffFf00f0F0f0",
  "Timestamp": 1715175519038
}

What’s next

Now that your webhook endpoint is set up, see the Polish the experience guide for tips on using transfer status data to build a complete user-facing notification experience.
AI coding reference — a compact summary of this page’s APIs, parameters, and patterns for use by AI coding assistants (following the llms.txt standard). Human readers can safely ignore this.llms.txt — Transfer status webhooksReceive, authenticate, and process Mesh transfer status events. Webhooks confirm on-chain transfer status (pending → succeeded/failed).Setup: (1) Register callback URI: Dashboard > Account > API keys > Webhooks. Save secret key — shown once only. (2) Whitelist Mesh IP: 20.22.113.37. (3) Verify HMAC-SHA256 signature.Signature verification: Compute HMAC-SHA256 over raw request body bytes (never parse/re-serialize first). Base64-encode the result. Compare to X-Mesh-Signature-256 header value.Node.js/Express — HMAC verification pattern:
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const rawBody = req.body;
  const receivedSig = req.headers['x-mesh-signature-256'];
  const computedSig = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('base64');
  if (computedSig !== receivedSig) return res.status(400).send('Invalid signature');
  res.sendStatus(200);
});
C# — HMAC-SHA256 verification:
public string GenerateHmacSignature(string payload, string webhookSecret)
{
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret));
    byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
    return Convert.ToBase64String(hash);
}
Python (Flask) — HMAC-SHA256 verification:
raw_body = request.get_data()
received_sig = request.headers.get("X-Mesh-Signature-256")
computed_sig = base64.b64encode(
    hmac.new(WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).digest()
).decode()
if computed_sig != received_sig:
    return "Invalid signature", 400
Response: Return 200 in < 200ms. Process event asynchronously after responding.Idempotency: EventId is stable across retries; Id changes per delivery attempt. Always deduplicate on EventId.Webhook call data: EventId | Id | SentTimestampTransfer data fields: TransferId | Timestamp | TransferStatus (pending/succeeded/failed) | TransactionId (your ID) | TxHash (on succeeded) | UserId | Token | Chain | SourceAmount (decimal — compute HMAC over raw bytes, never re-serialize) | SourceAccountProvider | DestinationAmount | DestinationAddress | RefundAddressStatuses: pending (initiated, no hash) | succeeded (final, on-chain confirmed, hash present) | failed (final, no hash)