Webhooks & signatures
Webhooks push events to your server as they happen: incoming messages, delivery receipts, async send results and session alerts.
Add a webhook
curl -X POST https://api.yasend.dev/v1/webhooks \
-H "Authorization: Bearer $YASEND_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/yasend/webhook", "secret": "'$WEBHOOK_SECRET'",
"eventTypes": ["message.incoming", "message.status", "message.sent", "message.failed", "session.alert"]}'
- Scope: add
sessionIdto receive one session's events only. Without it, you get every session's events. - Secret: always set a
secret, a long random string, so you can verify that requests come from YASend. - Test:
POST /v1/webhooks/{id}/testsends awebhook.testevent.
Events
Every event is a POST with a JSON body that carries event and sessionId.
event |
When | Extra fields |
|---|---|---|
message.incoming |
Someone messaged the number | id (WhatsApp id), remoteJid, fromMe, text, timestamp |
message.status |
A delivery receipt for a sent message | id (WhatsApp id), status (sent, delivered, read, played, error), statusCode |
message.sent |
An async send went out | message: the message object with waMessageId |
message.failed |
An async send failed for good | message: with error.code (e.g. EXPIRED, INVALID_JID, DELIVERY_UNKNOWN) |
session.alert |
The session needs attention (disconnects, health warnings…) | level, kind, message, timestamp |
{
"event": "message.incoming",
"sessionId": "8d4c1f0e-3b9a-4f57-9a3e-2c1d0b7a6e55",
"id": "3EB0C431D2B6A1E4F9A2",
"remoteJid": "[email protected]",
"fromMe": false,
"text": "Hi, is my order ready?",
"timestamp": 1791475200
}
Headers
| Header | |
|---|---|
X-YASend-Event |
The event type |
X-YASend-Delivery |
The delivery id. The same on every retry, so dedupe on it |
X-YASend-Timestamp |
Unix time when this attempt was signed |
X-YASend-Signature-V2 |
t=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>"> |
X-YASend-Signature |
sha256=<hex HMAC-SHA256 of the raw body> (self-hosted compatible; prefer V2) |
Verify the signature
Compute the HMAC over the raw request body, before any JSON parsing. Compare it in constant time, and reject timestamps older than about 5 minutes to stop replays.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/yasend/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-YASend-Signature-V2") || "";
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${t}.`).update(req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
// ... handle event (quickly), then:
res.sendStatus(204);
});
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/yasend/webhook")
def webhook():
parts = dict(p.split("=", 1) for p in request.headers.get("X-YASend-Signature-V2", "").split(",") if "=" in p)
expected = hmac.new(os.environ["WEBHOOK_SECRET"].encode(),
f"{parts.get('t')}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
abort(401)
event = request.get_json()
# ... handle event
return "", 204
// routes/api.php (Laravel)
Route::post('/yasend/webhook', function (Illuminate\Http\Request $request) {
parse_str(str_replace(',', '&', $request->header('X-YASend-Signature-V2', '')), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $request->getContent(), env('WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
abort(401);
}
$event = $request->json()->all();
// ... handle event
return response()->noContent();
});
app.MapPost("/yasend/webhook", async (HttpRequest req) =>
{
using var reader = new StreamReader(req.Body);
var body = await reader.ReadToEndAsync();
var parts = req.Headers["X-YASend-Signature-V2"].ToString().Split(',')
.Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
var key = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("WEBHOOK_SECRET")!);
var expected = Convert.ToHexString(HMACSHA256.HashData(key,
Encoding.UTF8.GetBytes($"{parts.GetValueOrDefault("t")}.{body}"))).ToLowerInvariant();
var fresh = long.TryParse(parts.GetValueOrDefault("t"), out var t)
&& Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) < 300;
if (!fresh || !CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(parts.GetValueOrDefault("v1") ?? "")))
return Results.Unauthorized();
var evt = JsonDocument.Parse(body);
// ... handle event
return Results.NoContent();
});
Answer fast; retries
| Rule | Details |
|---|---|
| Answer quickly | Reply with any 2xx within 10 seconds. Do slow work after answering (e.g. in a queue) |
| What counts as failure | Anything else: an error status, a timeout, or a connection error |
| Retry schedule | 30 s, 2 min, 10 min, 1 h and 6 h after a failure (6 attempts in total), then the delivery is marked failed |
| Duplicates | Delivery is at least once, so dedupe on X-YASend-Delivery. For message events, the message id works too |
| Ordering | Not guaranteed. Use timestamp and the status order |
| Allowed URLs | Public http(s) addresses only. Private, loopback and cloud-metadata addresses are refused. Redirects are not followed |
Delivery log and re-send
| Call | |
|---|---|
GET /v1/webhooks/{id}/deliveries?status=failed |
Every delivery from the last 30 days: attempts, your endpoint's last status, and when the next retry is due |
GET /v1/webhooks/{id}/deliveries/{deliveryId} |
One delivery, including the exact payload |
POST /v1/webhooks/{id}/deliveries/{deliveryId}/resend |
Sends the same payload again now, as a new delivery, after you fixed your endpoint. Only for deliveries that are still pending or are not message events: see below |
Message content is not kept. A delivery of a message event (message.*) holds the message only while a retry may still
need it. Once it is delivered, or has used up its attempts, its payload is replaced by {"event": …, "redacted": true}
and it can't be re-sent (409 PAYLOAD_NOT_KEPT). So answer quickly and store what you receive: we can't hand a message
to you twice after the fact.