YASend API

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 sessionId to 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}/test sends a webhook.test event.

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.