Webhooks are the authoritative event stream for card activity, ledger movements, and user wallet changes. This guide walks through a robust ingestion pipeline.
Delivery Contract
| Header | Value | Notes |
|---|---|---|
Content-Type |
application/json |
Always UTF-8 JSON. |
x-client-id |
Your tenant ID | Mirrors the API credential. |
x-signature |
HMAC-SHA256 | sha256=<hex> computed with the client secret. |
x-request-id |
UUID | Mirrors incoming X-Request-ID. Use for support. |
x-idempotency-key |
Optional | Propagates from source API call when available. |
Payload schema:
{
"event": "card_transaction",
"data": {
"id": "5b2fa934-1f1d-4b71-8d5a-a3e2f61ac1af",
"cardId": "0b1e9c6e-5d87-4f90-8c4d-0ad6f4ce4be5",
"transactionAmount": "12.34",
"transactionCurrency": "USD",
"type": "authorization",
"referenceId": "c8de3ebf-5b2d-4020-a7bb-65f88c3a37ce",
"timestamp": "2025-06-02T11:24:12Z"
}
}
Recommended Ingestion Flow
- Verify signature immediately.
- Persist raw payload (S3, Postgres, BigQuery) keyed by
data.id. - Detect duplicates (
data.idorx-idempotency-key). - Acknowledge quickly (
204 No Content). - Dispatch asynchronously to business logic consumers.
func HandleWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
defer r.Body.Close()
if !verifySignature(body, r.Header.Get("x-signature"), secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var evt Envelope
if err := json.Unmarshal(body, &evt); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := store.Insert(r.Context(), evt.Data.ID, body); err != nil {
if errors.Is(err, ErrDuplicate) {
w.WriteHeader(http.StatusNoContent)
return
}
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
queue.Publish(evt) // async processing
w.WriteHeader(http.StatusNoContent)
}
How PayCA Delivers Webhooks
- We capture every ledger or card event inside a Temporal workflow named
client-hook-<type>-<txID>– this keeps retries and ordering consistent per transaction. - The workflow looks up your active webhook URLs for that event type and records a
pendingrow inclient_hook_statusesfor each destination before the first attempt. - Payloads are JSON-encoded and signed with HMAC-SHA256 using your current client secret; the signature is sent back in the
x-signatureheader. - Delivery is a plain
POSTrequest with optional propagation of the originating API call's idempotency key viax-idempotency-keywhen PayCA has it. - Temporal retries on any non-2xx response or transport failure with exponential backoff (5s → 10s → 20s … capped at 30m) for up to 5 attempts; a success updates the status row to
sent, while a final failure leaves it asfailed.
Event Types
| Event | Fired When | Key Fields |
|---|---|---|
card_transaction |
Authorizations, settlements, adjustments, refunds, card lifecycle events | cardId, type, referenceId, linkedId |
account_transaction |
Mirror/revenue/card ledger changes | accountId, type, subtype, amount |
user_account_transaction |
User wallet deposits/withdrawals | userAccountId, type, referenceId |
Link card_transaction.referenceId to account_transaction.referenceId to see both sides of a movement.
Signature Verification
func verifySignature(body []byte, signature, secret string) bool {
parts := strings.SplitN(signature, "=", 2)
if len(parts) != 2 || parts[0] != "sha256" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(expected), []byte(parts[1])) == 1
}
Reject invalid signatures with 401. PayCA retries (up to 5 attempts) when it receives non-2xx responses.
Replay Strategy
If your consumer falls behind:
curl -X POST "$PAYCA_BASE_URL/v1/webhooks/resend?fromDate=2025-06-01T00:00:00Z" \
-H "x-client-id: $PAYCA_CLIENT_ID" \
-H "x-client-secret: $PAYCA_CLIENT_SECRET"
Recommendations:
- Replay in chronological order.
- Rate-limit the resend queue so you do not trigger
429. - Include monitoring so a long outage triggers alerts.
- The resend endpoint scans the
client_hook_statusestable for rows still markedfailedand requeues a lightweight workflow (client-hook-resend-<type>-<clientID>-<uuid>) per unique transaction so you do not receive duplicates. - Temporal handles the same 5-attempt retry loop during resend. As soon as your endpoint returns 2xx the status is flipped to
sentand future resends skip that transaction. - Use the
account,user, andcardcounters in the JSON response to confirm how many jobs were enqueued; thetotalfield is the sum of those buckets.
When to Trigger a Resend
- Your webhook receiver was unavailable long enough for PayCA's automatic retries to exhaust (status remains
failed). - You deployed a bug fix that previously caused 4xx responses and want to backfill only the affected time window using the optional
fromDatefilter. - You purposefully paused ingestion (e.g. maintenance) and need to flush the backlog after re-enabling your pipeline.
Storage Schema
Minimal schema for PostgreSQL:
CREATE TABLE payca_webhooks (
id UUID PRIMARY KEY,
event TEXT NOT NULL,
payload JSONB NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL,
request_id UUID,
card_id UUID,
account_id UUID,
user_account_id UUID
);
Denormalize card_id/account_id/user_account_id to accelerate dashboards. Index by delivered_at for retention policies.
Alerting & Dashboards
- Alert if retries exceed 3 attempts (
account_transactionwithstatus=failed). - Track webhook latency (delivery timestamp vs ingestion timestamp).
- Visualize spend, declines, and refunds from webhook payloads to support operations.
Security Best Practices
- Serve webhook endpoints over HTTPS with TLS 1.2+.
- Restrict inbound IPs to PayCA’s documented ranges.
- Rotate secrets quarterly; update the receiver without downtime by accepting both old/new secrets during the overlap window.
Continue with the Sandbox Playbook to generate webhook payloads on demand, or see Fees & Revenue Recognition for financial reporting.