Webhooks
Suward delivers signed events as payments and static-wallet deposits move through their stages. Verify the Ed25519 signature over the raw body, then trust the payload.
Configure the destination project-wide in the dashboard, or override per payment / per wallet with webhookUrl. Delivery is HTTP POST, JSON, to a public HTTPS endpoint — at-least-once with exponential-backoff retries on any non-2xx or timeout.
Events
| Event | Fires when | What you do |
|---|---|---|
payment.detected | An incoming transfer was seen on-chain; the payment is confirming. Not credited yet | Show “payment detected”; fulfil nothing. |
payment.accepted | The payment reached accepted: safe confirmations, balance credited | Act on it: ship, deliver, credit your user. |
payment.success | The payment reached success: finalized, funds withdrawable. Terminal | Treat the money as final. |
payment.failed | The payment reached failed: expired, cancelled, or invalidated. Terminal | Release the cart, close the order. |
static_deposit.accepted | A deposit reached accepted and was credited | Credit the customer's account balance. |
static_deposit.success | A deposit reached confirmed — finalized and withdrawable | Treat the money as final. |
static_deposit.failed | A deposit was rejected by screening (complianceRejected) and its credit reversed. Terminal | Reverse the credit on your side. |
Every event carries type, a unique eventId, a signed createdAt timestamp, and the full resource (payment or staticDeposit) — the same shape as the corresponding GET, so you rarely need a follow-up call.
{
"type": "payment.accepted",
"eventId": "evt_9c31b7",
"createdAt": 1769000450000,
"payment": {
"id": "pay_3f9a2c",
"projectId": "prj_1a2b",
"status": "accepted",
"subStatus": "completed",
"asset": "USDT_ARBITRUM",
"amount": "10000000",
"amountReceived": "10000000",
"externalId": "order-42",
"metadata": { "orderId": "42" }
}
}Headers
| Header | Value |
|---|---|
X-Suward-Signature | ed25519=<hex> — Ed25519 signature over the raw request body |
X-Suward-Timestamp | Unix milliseconds. Reject a delivery whose timestamp is more than 300 seconds from your own clock — the replay window |
X-Suward-Event-Id | Same value as eventId in the body |
X-Suward-Event-Type | Same value as type in the body |
X-Suward-Attempt | Which delivery attempt this is, starting at 1 |
Content-Type | application/json |
Verify, then trust
- Read
X-Suward-Signature(ed25519=<hex>) andX-Suward-Timestamp(Unix milliseconds). - Verify the Ed25519 signature over the raw request body against your project's
webhookPublicKey(dashboard → project settings) — before parsing JSON. - Reject events whose timestamp is more than 300 seconds from now (replay protection).
- Deduplicate on
eventId— the same event may be redelivered. Keep the ids in the same store as your business data, with a unique constraint on the column. - Don't assume ordering: read
statusandupdatedAtfrom the payload rather than inferring state from arrival order.
Never answer 4xx to an event you still want
A 4xx is treated as a temporary failure, exactly like a 5xx or a timeout: the event goes back in the queue, and after the last attempt it is dropped and never delivered again. Answer 5xx when something on your side might recover — your database is down, a downstream call timed out. Answer 200 when you have decided the event needs no work: a duplicate, an older state, a type you don't handle. Keep 4xx for a body whose signature does not verify.
Raw body, not the parsed object
Frameworks that JSON-parse before your handler runs will re-serialize with different whitespace
and key order, and every signature will fail. Capture the raw bytes first — in Express,
express.raw({ type: "application/json" }) on this route only.
webhookPublicKey is a PKIX/SPKI PEM block — it starts with -----BEGIN PUBLIC KEY-----, not raw hex. Parse the PEM, then verify with the Ed25519 key inside it.
The TypeScript SDK ships this as a ready-made helper (shown below). Neither the Go SDK nor Python — which has no official Suward client — provides an equivalent, so the Go and Python tabs verify the signature by hand with each language's standard Ed25519 primitives.
The signature and header handling below is real code. The storage calls — store, db, claimEventId, loadState — are pseudocode standing in for your database; substitute your own. Two properties are what make the handler correct, whatever you use: the eventId column carries a unique constraint, and the mark commits in the same transaction as the work it guards. A process-local set or map is not enough — it empties on restart and is not shared between replicas of your service, so both cases let a redelivery run your fulfilment twice.
import { webhooks } from "@crylabsorg/suward-sdk";
app.post("/webhooks/suward", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8"); // SDK takes a string, not the raw Buffer
const ok = await webhooks.WebhooksHelper.verifySignature(
body,
req.header("X-Suward-Signature"),
process.env.SUWARD_WEBHOOK_PUBLIC_KEY,
req.header("X-Suward-Timestamp")
);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(body);
const resource = event.payment ?? event.staticDeposit;
try {
await store.transaction(async (tx) => {
// INSERT … ON CONFLICT DO NOTHING against a UNIQUE index on eventId.
// The mark and the work below commit together or not at all.
if (!(await tx.claimEventId(event.eventId))) return;
const known = await tx.loadState(resource.id);
if (known && known.updatedAt >= resource.updatedAt) return;
if (event.type === "payment.accepted") await fulfil(tx, event.payment);
if (event.type === "payment.success") await settle(tx, event.payment);
await tx.saveState(resource.id, resource);
});
} catch {
return res.sendStatus(500); // nothing committed — ask for a retry
}
res.sendStatus(200);
});package main
import (
"context"
"crypto/ed25519"
"crypto/x509"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const replayWindow = 300 * time.Second
var publicKey ed25519.PublicKey
// eventStore is your database. Apply must run as ONE transaction: insert
// eventID into a table with a unique index, return without calling do when that
// insert conflicts or when the stored updatedAt is not older than updatedAt,
// and otherwise commit the mark together with everything do wrote.
type eventStore interface {
Apply(ctx context.Context, eventID, resourceID string, updatedAt int64, do func(*sql.Tx) error) error
}
var store eventStore
type event struct {
Type string `json:"type"`
EventID string `json:"eventId"`
Payment json.RawMessage `json:"payment"`
StaticDeposit json.RawMessage `json:"staticDeposit"`
}
type resourceState struct {
ID string `json:"id"`
UpdatedAt int64 `json:"updatedAt"`
}
func init() {
key, err := loadPublicKey(os.Getenv("SUWARD_WEBHOOK_PUBLIC_KEY"))
if err != nil {
panic(err)
}
publicKey = key
}
// loadPublicKey parses the PKIX/SPKI PEM block returned as project.webhookPublicKey.
func loadPublicKey(pemText string) (ed25519.PublicKey, error) {
block, _ := pem.Decode([]byte(pemText))
if block == nil {
return nil, errors.New("webhookPublicKey is not a PEM block")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(ed25519.PublicKey)
if !ok {
return nil, errors.New("webhookPublicKey is not an Ed25519 key")
}
return key, nil
}
func fresh(timestampHeader string) bool {
millis, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
return false
}
age := time.Since(time.UnixMilli(millis))
if age < 0 {
age = -age
}
return age <= replayWindow
}
func handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) // raw bytes — verify before parsing
sig := strings.TrimPrefix(r.Header.Get("X-Suward-Signature"), "ed25519=")
raw, err := hex.DecodeString(sig)
if err != nil || !ed25519.Verify(publicKey, body, raw) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
if !fresh(r.Header.Get("X-Suward-Timestamp")) {
http.Error(w, "stale event", http.StatusBadRequest)
return
}
var ev event
if err := json.Unmarshal(body, &ev); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
resource := ev.Payment
if len(resource) == 0 {
resource = ev.StaticDeposit
}
var state resourceState
if err := json.Unmarshal(resource, &state); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
err = store.Apply(r.Context(), ev.EventID, state.ID, state.UpdatedAt, func(tx *sql.Tx) error {
switch ev.Type {
case "payment.accepted":
return fulfil(tx, ev.Payment)
case "payment.success":
return settle(tx, ev.Payment)
}
return nil
})
if err != nil {
http.Error(w, "retry later", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}import json
import os
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
REPLAY_WINDOW_MS = 300_000
def load_public_key(public_key_pem: str) -> Ed25519PublicKey:
key = load_pem_public_key(public_key_pem.encode()) # -----BEGIN PUBLIC KEY----- (PKIX/SPKI)
if not isinstance(key, Ed25519PublicKey):
raise ValueError("webhookPublicKey is not an Ed25519 key")
return key
PUBLIC_KEY = load_public_key(os.environ["SUWARD_WEBHOOK_PUBLIC_KEY"])
def verify(raw_body: bytes, signature_header: str) -> bool:
try:
signature = bytes.fromhex(signature_header.removeprefix("ed25519="))
except (AttributeError, ValueError):
return False
try:
PUBLIC_KEY.verify(signature, raw_body)
except InvalidSignature:
return False
return True
def fresh(timestamp_header: str) -> bool:
try:
sent_at = int(timestamp_header)
except (TypeError, ValueError):
return False
return abs(time.time() * 1000 - sent_at) <= REPLAY_WINDOW_MS
def handle(raw_body: bytes, signature_header: str, timestamp_header: str) -> int:
if not verify(raw_body, signature_header) or not fresh(timestamp_header):
return 400
event = json.loads(raw_body)
resource = event.get("payment") or event["staticDeposit"]
try:
# db is your database. One transaction covers the eventId mark and the
# work it guards; claim_event is an insert against a unique index.
with db.transaction() as tx:
if not tx.claim_event(event["eventId"]):
return 200
known = tx.load_state(resource["id"])
if known and known["updatedAt"] >= resource["updatedAt"]:
return 200
if event["type"] == "payment.accepted":
fulfil(tx, event["payment"])
elif event["type"] == "payment.success":
settle(tx, event["payment"])
tx.save_state(resource["id"], resource)
except Exception:
return 500 # nothing committed — ask for a retry
return 200In TypeScript, use the SDK helper instead of the hand-rolled version above: webhooks.WebhooksHelper.verifySignature(rawBody, signatureHeader, publicKey, timestampHeader). Go and Python don't have one — the manual versions above are what you ship. → SDK reference
The helper's fourth argument is the X-Suward-Timestamp header: it refuses a header more than 300 seconds away from its own clock, which is the replay window. Pass the header through untouched rather than a value you stored earlier, and keep your server clock in sync.
Retries and acknowledgement
Respond with any 2xx to acknowledge. Handler still failing? The event retries — idempotent handlers make retries free.
- Delivery is at-least-once: the same
eventIdcan arrive more than once. Deduplicate. - Every response outside 2xx is retried, 4xx included, as is a timeout.
- Up to 20 attempts per event. The wait starts at 30 seconds and doubles — 30 s, 1 min, 2 min, 4 min — until it caps at 24 hours, which stretches the last attempt to roughly eight and a half days after the first.
- After the 20th attempt the event is marked failed and never delivered again. Recover it by reading the object with a GET.
X-Suward-Attempttells you which attempt you are looking at, starting at 1.- Acknowledge fast: do the work asynchronously if it is slow, rather than holding the connection open until it times out.
- Ordering is not guaranteed. A
payment.successcan arrive before you finish processingpayment.accepted— compareupdatedAtinstead of trusting arrival order.
No public endpoint yet?
Poll instead — GET /v1/payments/{paymentId} and GET /v1/static-wallets/{staticWalletId}/deposits return the same objects the webhooks carry. Move to webhooks before production: polling loses you the timing that Accepted exists to give you.
Next: which stage means what → Settlement & finality.
Something unclear? payments@suward.com — we treat every integration question as a documentation defect.