Receive webhooks

Passify can POST signed events to your server when attestations, KYC sessions, or compliance rules change — so you react in real time instead of polling.

Supported events

EventFires when
attestation.issuedA new on-chain attestation is issued for a wallet.
attestation.expiringAn attestation is within its expiry window and should be renewed.
kyc.status_changedA KYC session changes status (approved, rejected, expired).
rule.updatedA compliance rule for a mint configuration is changed.

Create an endpoint

In the dashboard under Webhooks, add your receiver URL and select the events to subscribe to. Passify generates a signing secret (whsec_…) shown once — store it securely; you use it to verify every delivery.

Payload & headers

Each delivery is a JSON POST with these headers:

HeaderValue
Passify-Signaturet=<unix>,v1=<hex hmac-sha256>
Passify-Event-IdUnique event id (evt_…) — use for idempotency.
Passify-Event-Typee.g. attestation.issued
Passify-TimestampUnix seconds when signed.
Example body
{
  "id": "evt_lm3k_9f2a",
  "type": "attestation.issued",
  "created_at": "2026-06-30T12:00:00.000Z",
  "data": {
    "attestation_id": "att_lm3k_9f2a",
    "user_pubkey": "7xKXtg2...",
    "schema": "kyc_individual_v1",
    "expires_at": "2027-01-15T00:00:00.000Z"
  }
}

Verifying signatures

The signed payload is {t}.{raw body}, HMAC-SHA256 with your signing secret. Always verify against the raw request body (do not re-serialize JSON).

TypeScript / JavaScript (SDK)

typescript
import { webhooks } from "@passify/sdk";

// rawBody is the exact string you received
const ok = await webhooks.verifySignature({
  secret: process.env.PASSIFY_WEBHOOK_SECRET!,
  payload: rawBody,
  signature: req.headers["passify-signature"],
});
if (!ok) return res.status(400).end("invalid signature");

Node (no SDK)

javascript
import crypto from "node:crypto";

function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"));
}

curl (compute the comparison locally)

bash
# Given the raw body in body.json and t from the Passify-Timestamp header:
printf '%s.%s' "$T" "$(cat body.json)" \
  | openssl dgst -sha256 -hmac "$PASSIFY_WEBHOOK_SECRET" -hex

Python

python
import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Go

go
func Verify(secret, rawBody, header string, tolerance int64) bool {
    parts := map[string]string{}
    for _, kv := range strings.Split(header, ",") {
        p := strings.SplitN(kv, "=", 2)
        parts[p[0]] = p[1]
    }
    t, _ := strconv.ParseInt(parts["t"], 10, 64)
    if abs(time.Now().Unix()-t) > tolerance {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(fmt.Sprintf("%d.%s", t, rawBody)))
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}

Replay protection

  • Reject deliveries whose Passify-Timestamp is outside a tolerance window (default 5 minutes) — shown in every example above.
  • Deduplicate on Passify-Event-Id: store processed ids and ignore repeats. Deliveries can legitimately be retried, so your handler must be idempotent.
  • Always return 2xx quickly once you have stored the event; do heavy work asynchronously.

Retry behavior

Passify makes immediate inline attempts on each event. Failed deliveries are scheduled with exponential backoff (10s, 20s, 40s, … capped at 1h) up to six attempts. You can also replay any delivery manually from the dashboard.

Last updated