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

EventFires whenWhat you do
payment.detectedAn incoming transfer was seen on-chain; the payment is confirming. Not credited yetShow “payment detected”; fulfil nothing.
payment.acceptedThe payment reached accepted: safe confirmations, balance creditedAct on it: ship, deliver, credit your user.
payment.successThe payment reached success: finalized, funds withdrawable. TerminalTreat the money as final.
payment.failedThe payment reached failed: expired, cancelled, or invalidated. TerminalRelease the cart, close the order.
static_deposit.acceptedA deposit reached accepted and was creditedCredit the customer's account balance.
static_deposit.successA deposit reached confirmed — finalized and withdrawableTreat the money as final.
static_deposit.failedA deposit was rejected by screening (complianceRejected) and its credit reversed. TerminalReverse 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.

payment.accepted.json
{
	"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

HeaderValue
X-Suward-Signatureed25519=<hex> — Ed25519 signature over the raw request body
X-Suward-TimestampUnix milliseconds. Reject a delivery whose timestamp is more than 300 seconds from your own clock — the replay window
X-Suward-Event-IdSame value as eventId in the body
X-Suward-Event-TypeSame value as type in the body
X-Suward-AttemptWhich delivery attempt this is, starting at 1
Content-Typeapplication/json

Verify, then trust

  1. Read X-Suward-Signature (ed25519=<hex>) and X-Suward-Timestamp (Unix milliseconds).
  2. Verify the Ed25519 signature over the raw request body against your project's webhookPublicKey (dashboard → project settings) — before parsing JSON.
  3. Reject events whose timestamp is more than 300 seconds from now (replay protection).
  4. 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.
  5. Don't assume ordering: read status and updatedAt from 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.

webhook.ts
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);
});
webhook.go
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)
}
webhook.py
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 200

In 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 eventId can 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-Attempt tells 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.success can arrive before you finish processing payment.accepted — compare updatedAt instead 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.