Webhooks
Rach sends signed HTTP POST requests to your configured webhook URL when payment and wallet events occur.
Base URL: https://payments-api-dev-966260606560.europe-west2.run.app
Setup
Configure Your Webhook URL
POST /api/v1/settings/webhook
POST /api/v1/settings/webhook
Authorization: Bearer <jwt>
Content-Type: application/json
{
"webhook_url": "https://yoursite.com/webhooks/rach",
"wallet_webhook_enabled": true
}| Field | Type | Required | Description |
|---|---|---|---|
webhook_url | string (URI) | ✅ | Your HTTPS endpoint |
wallet_webhook_enabled | boolean | — | Enable WaaS deposit events (default false) |
Response 200 OK (first-time setup)
{
"webhook_url": "https://yoursite.com/webhooks/rach",
"webhook_secret": "whsec_AbCdEfGh1234...",
"wallet_webhook_enabled": true,
"message": "Webhook configured"
}⚠️ The
webhook_secretis only returned on first setup. Store it immediately in your secrets manager. To get a new secret use the rotate endpoint.
Get Current Configuration
GET /api/v1/settings/webhook
The secret is never included in the GET response.
Rotate Webhook Secret
POST /api/v1/settings/webhook/rotate-secret
Generates a new whsec_* secret. The old secret is immediately invalidated — update your verification code before rotating.
POST /api/v1/settings/webhook/rotate-secret
Authorization: Bearer <jwt>Response:
{
"webhook_secret": "whsec_NewSecretHere...",
"message": "Secret rotated — update your verification code now"
}Send a Test Event
POST /api/v1/settings/webhook/test
Sends a webhook.test event to verify connectivity and that your signature verification logic is working.
POST /api/v1/settings/webhook/test
Authorization: Bearer <jwt>Signature Verification
Every webhook request includes an X-Webhook-Signature header containing the HMAC-SHA256 of the raw request body, signed with your whsec_* secret.
Always verify the signature before processing an event.
const crypto = require('crypto');
app.post('/webhooks/rach', express.raw({ type: '*/*' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const expectedSig = crypto
.createHmac('sha256', process.env.RACH_WEBHOOK_SECRET)
.update(req.body) // raw Buffer — do NOT parse before this step
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
handleEvent(event);
res.sendStatus(200);
});import hmac, hashlib
from flask import request, abort
@app.route('/webhooks/rach', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature')
raw_body = request.get_data()
expected = hmac.new(
RACH_WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json(force=True)
handle_event(event)
return '', 200func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Webhook-Signature")
mac := hmac.New(sha256.New, []byte(os.Getenv("RACH_WEBHOOK_SECRET")))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event map[string]interface{}
json.Unmarshal(body, &event)
handleEvent(event)
w.WriteHeader(http.StatusOK)
}Event Types
Checkout / Payment Gateway Events
| Event | Fired When |
|---|---|
payment.detected | Deposit seen on-chain — not yet confirmed |
payment.confirmed | Deposit fully confirmed — safe to fulfill order |
payment.failed | Deposit detected but failed to confirm |
payment.expired | Session timed out before payment received |
webhook.test | Test event from dashboard or /settings/webhook/test |
WaaS (Wallet-as-a-Service) Events
Enabled by setting wallet_webhook_enabled: true in your webhook config.
| Event | Fired When |
|---|---|
wallet.deposit.detected | Incoming deposit seen on-chain (not confirmed) |
wallet.deposit.confirmed | Deposit confirmed — safe to credit customer balance |
Payload Shapes
payment.confirmed
{
"event": "payment.confirmed",
"data": {
"session_id": "a1b2c3d4-e5f6-...",
"reference": "ORDER-001",
"amount": 100.00,
"currency": "USD",
"amount_crypto": "100.00",
"crypto_currency": "USDT",
"network": "BSC",
"tx_hash": "0xabc123...",
"customer_email": "customer@example.com",
"paid_at": "2026-06-25T14:12:33Z",
"metadata": {
"order_id": "ord_abc123"
}
}
}wallet.deposit.detected / wallet.deposit.confirmed
{
"event": "wallet.deposit.confirmed",
"data": {
"customer_id": "cus_abc123",
"network": "ETH",
"address": "0x1a2b3c...",
"amount": "100.00",
"currency": "USDT",
"tx_hash": "0xabc...",
"confirmations": 12,
"status": "confirmed",
"detected_at": "2026-01-01T00:00:00Z",
"confirmed_at": "2026-01-01T00:02:00Z"
}
}TRX, SOL, LTC, BCH, XRP: These networks use a settlement delay. The
confirmationsfield will always be0even whenstatusis"confirmed". Usestatus === "confirmed"as the authoritative signal, not theconfirmationscount.
webhook.test
{
"event": "webhook.test",
"data": {
"message": "This is a test webhook event",
"timestamp": "2026-06-25T14:00:00Z"
}
}Retry Policy
If your endpoint does not return a 2xx response, Rach will retry delivery with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 5 minutes |
| 2nd retry | 30 minutes |
| 3rd retry | 2 hours |
| 4th retry | 12 hours |
| Final | 24 hours |
After the final attempt, the event is marked as failed. You can view delivery logs in the dashboard.
Best Practices
- Always verify the signature before processing any event
- Use
payment.confirmed(notpayment.detected) to fulfill orders - Respond quickly — return
200immediately and process async. If processing takes >5s, the delivery is marked as failed - Be idempotent — you may receive the same event more than once; use
session_idortx_hashas a deduplication key - Validate the
amount— confirm it matches your order total before fulfilling
