Skip to content
StraventaDocs
Guides

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:

HeaderPurpose
X-Straventa-SignatureTimestamp + HMAC-SHA256 combined header — t=<unix>,v1=<hex>. Parse before verifying.
X-Straventa-Delivery-IdUUID per delivery attempt. Retries produce a new delivery ID.
X-Straventa-Event-KindEvent 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…
PartMeaning
tUnix timestamp (seconds) when the delivery was signed
v1hex(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

  1. Split X-Straventa-Signature on , to extract t=<unix> and v1=<hex>.
  2. Reject if |now − t| > 300 seconds.
  3. Construct the signing payload: "<t>" + "." + raw_body_bytes.
  4. Compute HMAC-SHA256(secret, signing_payload) and hex-encode it.
  5. Compare with v1 using 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"
fi
import { 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', 200
package 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))
AttemptBase delayWith jitter range
12s1s–3s
24s2s–6s
38s4s–12s
532s16s–48s
10512s4.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

EventTriggered when
payment_intent.createdA new payment intent is created (POST /v1/payments/intents)
payment.completedPayment reaches terminal success state (PSP captured funds)
payment.refundedRefund completes (full or partial)
payment_intent.expiredIntent expires before completion
payment.failedPayment attempt fails terminally
settlement.completedFunds 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:3000

Use 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.com

See Debug a failed webhook for the full diagnostic recipe.

Was this page helpful?

On this page