Webhooks
Receive real-time payment events with signature verification, retry logic, and replay support
Webhooks
505pay sends HTTP POST requests to your configured endpoint when payment events occur. Verify the signature on every delivery to ensure authenticity.
Registering an endpoint
curl -X POST https://sandbox.505pay.link/v1/payments/settings/webhooks \
-H "Authorization: Bearer $PAYOPS_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example.com/webhooks/505pay",
"events": ["payment.completed", "payment.refunded", "payment_intent.expired"]
}'The response includes secret (the signing key for HMAC verification — shown ONCE) and id (use it to update / disable / replay).
Signature verification
Every webhook delivery includes the following headers:
| Header | Purpose |
|---|---|
X-Straventa-Signature | Timestamp + HMAC-SHA256 combined header — t=<unix>,v1=<hex>. Parse before verifying. |
X-Straventa-Delivery-Id | UUID per delivery attempt. Retries produce a new delivery ID. |
X-Straventa-Event-Kind | Event kind string (e.g. payment.completed). Mirrors event in the body. |
Signature format
The X-Straventa-Signature header uses a Stripe-style combined format:
X-Straventa-Signature: t=1715600000,v1=ab12cd34ef56…| Part | Meaning |
|---|---|
t | Unix timestamp (seconds) when the delivery was signed |
v1 | hex(HMAC-SHA256(secret, "<t>.<raw_body>")) |
The signing payload is the string "<unix_timestamp>.<raw_body>" — a dot-separated concatenation of the timestamp and the raw request body bytes.
Reject the delivery if |now − t| > 5 minutes to prevent replay attacks. This is enforced server-side on verification too.
Compute the HMAC over the raw request body bytes, not over a parsed/re-serialised JSON object. Body whitespace and key ordering matter for the HMAC; libraries that auto-parse JSON before your handler runs will silently corrupt the bytes. Use the raw-body middleware for your framework (Express express.raw({type: '*/*'}), Flask request.get_data(), Go io.ReadAll(r.Body)).
Verification algorithm
- Split
X-Straventa-Signatureon,to extractt=<unix>andv1=<hex>. - Reject if
|now − t| > 300seconds. - Construct the signing payload:
"<t>" + "." + raw_body_bytes. - Compute
HMAC-SHA256(secret, signing_payload)and hex-encode it. - Compare with
v1using a constant-time equality function.
Verification examples
# Full X-Straventa-Signature header value, e.g. "t=1715600000,v1=ab12cd34..."
SIG_HEADER="t=1715600000,v1=ab12cd34ef56..."
BODY=$(cat body.bin) # raw bytes — do not re-format JSON
# Extract timestamp and hex signature
TIMESTAMP=$(echo "$SIG_HEADER" | grep -oP '(?<=t=)[^,]+')
RECEIVED_V1=$(echo "$SIG_HEADER" | grep -oP '(?<=v1=)[a-f0-9]+')
# Construct signing payload: "<timestamp>.<body>"
SIGNING_PAYLOAD="${TIMESTAMP}.${BODY}"
# Compute expected signature
EXPECTED_V1=$(printf '%s' "$SIGNING_PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}')
# Compare (timing-safe comparison not available in bash — use application code)
if [ "$RECEIVED_V1" = "$EXPECTED_V1" ]; then
echo "Signature valid"
else
echo "Signature INVALID"
fiimport { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody: Buffer, sigHeader: string, secret: string): boolean {
// Parse "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(
sigHeader.split(',').map(p => p.split('=') as [string, string])
);
const timestamp = parts['t'];
const receivedHex = parts['v1'];
if (!timestamp || !receivedHex) return false;
// Reject if |now - t| > 5 minutes
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skewSeconds > 300) return false;
// Signing payload is "<timestamp>.<raw_body>"
const signingPayload = Buffer.concat([
Buffer.from(timestamp, 'utf8'),
Buffer.from('.', 'utf8'),
rawBody,
]);
const expectedHex = createHmac('sha256', secret).update(signingPayload).digest('hex');
const expectedBuf = Buffer.from(expectedHex, 'hex');
const receivedBuf = Buffer.from(receivedHex, 'hex');
if (expectedBuf.length !== receivedBuf.length) return false;
return timingSafeEqual(expectedBuf, receivedBuf);
}
// Express handler — note express.raw() so req.body is the raw Buffer
app.post('/webhooks/505pay', express.raw({ type: '*/*' }), (req, res) => {
const sigHeader = req.headers['x-straventa-signature'] as string;
const rawBody = req.body as Buffer;
if (!verifyWebhook(rawBody, sigHeader, process.env.PAYOPS_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'invalid_signature' });
}
const event = JSON.parse(rawBody.toString('utf8'));
const deliveryId = req.headers['x-straventa-delivery-id'];
const eventKind = req.headers['x-straventa-event-kind'];
// Dedupe using your own idempotency key or deliveryId before processing
console.log('Received event:', eventKind, deliveryId);
res.status(200).send('OK');
});import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['PAYOPS_WEBHOOK_SECRET']
@app.route('/webhooks/505pay', methods=['POST'])
def handle_webhook():
sig_header = request.headers.get('X-Straventa-Signature', '')
raw_body = request.get_data() # raw bytes — NOT request.json
# Parse "t=<unix>,v1=<hex>"
parts = dict(part.split('=', 1) for part in sig_header.split(',') if '=' in part)
timestamp = parts.get('t')
received_v1 = parts.get('v1')
if not timestamp or not received_v1:
abort(401)
# Reject if |now - t| > 5 minutes
if abs(time.time() - int(timestamp)) > 300:
abort(401)
# Signing payload is "<timestamp>.<raw_body>"
signing_payload = timestamp.encode('utf-8') + b'.' + raw_body
expected_v1 = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signing_payload,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(received_v1, expected_v1):
abort(401)
event = request.get_json()
delivery_id = request.headers.get('X-Straventa-Delivery-Id')
event_kind = request.headers.get('X-Straventa-Event-Kind')
# Dedupe using your own idempotency key or delivery_id before processing
print('Received event:', event_kind, delivery_id)
return 'OK', 200package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// verifyWebhook parses "t=<unix>,v1=<hex>" and validates the HMAC.
func verifyWebhook(rawBody []byte, sigHeader, secret string) bool {
parts := make(map[string]string)
for _, part := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
tsStr, ok1 := parts["t"]
receivedHex, ok2 := parts["v1"]
if !ok1 || !ok2 {
return false
}
// Reject if |now - t| > 5 minutes
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil || int64(math.Abs(float64(time.Now().Unix()-ts))) > 300 {
return false
}
// Signing payload is "<timestamp>.<raw_body>"
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", ts)
mac.Write(rawBody)
expectedHex := hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(expectedHex), []byte(receivedHex)) == 1
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
sigHeader := r.Header.Get("X-Straventa-Signature")
secret := os.Getenv("PAYOPS_WEBHOOK_SECRET")
if !verifyWebhook(rawBody, sigHeader, secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
deliveryID := r.Header.Get("X-Straventa-Delivery-Id")
eventKind := r.Header.Get("X-Straventa-Event-Kind")
// Dedupe using your own idempotency key or deliveryID before processing
_ = deliveryID
_ = eventKind
w.WriteHeader(http.StatusOK)
}Retry schedule
If your endpoint returns a non-2xx status or times out, 505pay retries with exponential backoff plus jitter:
delay = min(2^attempt, 3600) seconds * uniform([0.5, 1.5))| Attempt | Base delay | With jitter range |
|---|---|---|
| 1 | 2s | 1s–3s |
| 2 | 4s | 2s–6s |
| 3 | 8s | 4s–12s |
| 5 | 32s | 16s–48s |
| 10 | 512s | 4.3m–12.8m |
| 12+ | 3600s (max) | 30m–90m |
Your endpoint should respond within 30 seconds. Return 200 OK as soon as you accept the event — do not wait for processing to complete.
Dead letter queue
After 10 consecutive failures, the webhook subscription is automatically disabled and the delivery row is marked dead. You will see this on the dashboard's Webhooks page.
To re-enable a subscription:
curl -X PATCH https://sandbox.505pay.link/v1/payments/settings/webhooks/$WEBHOOK_ID \
-H "Authorization: Bearer $PAYOPS_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"active": true}'Test-fire a delivery
Use the test-fire endpoint to send a synthetic delivery to your endpoint without waiting for a real payment:
curl -X POST https://sandbox.505pay.link/v1/payments/settings/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $PAYOPS_SECRET_KEY"This re-sends a synthetic payload signed with your current secret to your current endpoint URL.
Event types
| Event | Triggered when |
|---|---|
payment_intent.created | A new payment intent is created (POST /v1/payments/intents) |
payment.completed | Payment reaches terminal success state (PSP captured funds) |
payment.refunded | Refund completes (full or partial) |
payment_intent.expired | Intent expires before completion |
payment.failed | Payment attempt fails terminally |
settlement.completed | Funds settled to your bank account |
Local development
Testing webhooks against localhost requires exposing your port to the internet. The recommended tools are:
ngrok
ngrok http 3000
# → Forwarding https://abc123.ngrok.io → localhost:3000Use the https://abc123.ngrok.io/webhooks/505pay URL when registering your webhook in the dashboard.
Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000
# → https://random-name.trycloudflare.comSee Debug a failed webhook for the full diagnostic recipe.
Was this page helpful?