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
| Event | Fires when |
|---|---|
attestation.issued | A new on-chain attestation is issued for a wallet. |
attestation.expiring | An attestation is within its expiry window and should be renewed. |
kyc.status_changed | A KYC session changes status (approved, rejected, expired). |
rule.updated | A 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:
| Header | Value |
|---|---|
Passify-Signature | t=<unix>,v1=<hex hmac-sha256> |
Passify-Event-Id | Unique event id (evt_…) — use for idempotency. |
Passify-Event-Type | e.g. attestation.issued |
Passify-Timestamp | Unix seconds when signed. |
{
"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)
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)
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)
# 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" -hexPython
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
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-Timestampis 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
2xxquickly 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