Webhook Implementation Examples
This guide provides complete, production-ready server code samples for receiving, verifying, and routing Amwal real-time webhook events across multiple backend languages and frameworks.
Receiver Pipeline Architecture
Rendering diagram...
1. Node.js & Express (TypeScript)
In Express, capture the raw Buffer before JSON parsing to ensure cryptographic verification matches the exact incoming payload bytes:
import crypto from 'node:crypto';
import express, { Request, Response } from 'express';
const app = express();
// 1. Capture raw request body buffer
app.use(
express.json({
verify: (req: any, _res, buf) => {
req.rawBody = buf;
},
})
);
const AMWAL_PUBLIC_KEY = process.env.AMWAL_PUBLIC_KEY || `-----BEGIN PUBLIC KEY-----
YOUR_AMWAL_PUBLIC_KEY_PEM_HERE
-----END PUBLIC KEY-----`;
function verifySignature(rawBody: Buffer, signatureBase64: string): boolean {
try {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(rawBody);
return verifier.verify(
{
key: AMWAL_PUBLIC_KEY,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
signatureBase64,
'base64'
);
} catch (err) {
console.error('Signature verification error:', err);
return false;
}
}
app.post('/webhook', async (req: Request, res: Response) => {
const signature = req.headers['x-signature'] as string;
const apiKey = req.headers['x-api-key'] as string;
if (!signature || !req.rawBody) {
return res.status(400).json({ error: 'Missing signature or payload' });
}
// 2. Validate RSA-PSS signature
const isValid = verifySignature(req.rawBody, signature);
if (!isValid) {
console.warn('[Webhook] Rejected: Invalid cryptographic signature');
return res.status(403).json({ error: 'Invalid signature' });
}
const { event_type, data } = req.body;
console.log(`[Webhook] Authenticated event: ${event_type} (Txn: ${data?.id})`);
// 3. Immediately acknowledge webhook delivery
res.status(200).json({ status: 'received' });
// 4. Route event asynchronously
try {
switch (event_type) {
case 'order.success':
// Card or 0% Installment payment approved
console.log(`Order ${data.order_details?.order_id} marked as PAID. Amount: ${data.amount} SAR`);
break;
case 'order.updated':
// Refund processed — inspect refund_tracker
console.log(`Refund on Txn ${data.id}. Refunded: ${data.refunded_amount} SAR`);
break;
case 'order.failed':
// Payment failed or declined by issuing bank
console.warn(`Payment failed for ${data.id}. Reason: ${data.failure_reason}`);
break;
case 'installment.tracker.approved':
console.log(`Installment plan approved for Txn ${data.id} with status A`);
break;
case 'installment.tracker.rejected':
console.warn(`Installment plan declined for Txn ${data.id} with status R`);
break;
case 'order.disputed':
console.warn(`Chargeback status update: ${data.disputed_status} (Due: ${data.due_date})`);
break;
case 'payment_link.expired':
console.log(`Payment link ${data.payment_link_id} expired`);
break;
case 'modal.closed':
console.log(`Customer closed checkout modal for ${data.payment_link_id}`);
break;
default:
console.log(`Unhandled webhook event: ${event_type}`);
}
} catch (handlerError) {
console.error('[Webhook Handler Error]:', handlerError);
}
});
app.listen(3000, () => console.log('Webhook receiver running on port 3000'));2. Python & FastAPI
FastAPI handles raw payload inspection directly through await request.body():
import os
import json
from fastapi import FastAPI, Request, HTTPException, status
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_public_key
import base64
app = FastAPI()
AMWAL_PUBLIC_KEY_PEM = os.getenv("AMWAL_PUBLIC_KEY", """-----BEGIN PUBLIC KEY-----
YOUR_AMWAL_PUBLIC_KEY_PEM_HERE
-----END PUBLIC KEY-----""").encode("utf-8")
public_key = load_pem_public_key(AMWAL_PUBLIC_KEY_PEM)
def verify_amwal_signature(raw_body: bytes, signature_base64: str) -> bool:
try:
signature_bytes = base64.b64decode(signature_base64)
public_key.verify(
signature_bytes,
raw_body,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH
),
hashes.SHA256()
)
return True
except Exception as e:
print(f"Signature verification failed: {e}")
return False
@app.post("/webhook")
async def handle_amwal_webhook(request: Request):
signature = request.headers.get("x-signature")
if not signature:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing X-Signature header")
raw_body = await request.body()
# 1. Verify RSA-PSS Signature
if not verify_amwal_signature(raw_body, signature):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
payload = json.loads(raw_body)
event_type = payload.get("event_type")
data = payload.get("data", {})
print(f"Verified event received: {event_type} for ID: {data.get('id')}")
# 2. Dispatch event to handler
if event_type == "order.success":
# Mark order as completed in database
pass
elif event_type == "order.updated":
# Process refund reconciliation
pass
return {"status": "ok"}3. PHP (Vanilla & Laravel)
In PHP, use openssl_verify with OPENSSL_ALGO_SHA256 and PSS padding:
<?php
// webhook.php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (empty($signature) || empty($rawBody)) {
http_response_code(400);
echo json_encode(['error' => 'Missing signature or body']);
exit;
}
$publicKeyPem = getenv('AMWAL_PUBLIC_KEY') ?: <<<EOD
-----BEGIN PUBLIC KEY-----
YOUR_AMWAL_PUBLIC_KEY_PEM_HERE
-----END PUBLIC KEY-----
EOD;
// Verify RSA-PSS SHA-256 Signature
$signatureBytes = base64_decode($signature);
$publicKeyResource = openssl_pkey_get_public($publicKeyPem);
$verifyResult = openssl_verify(
$rawBody,
$signatureBytes,
$publicKeyResource,
OPENSSL_ALGO_SHA256
);
if ($verifyResult !== 1) {
http_response_code(403);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Signature is valid — decode payload and process
$payload = json_decode($rawBody, true);
$eventType = $payload['event_type'] ?? '';
$data = $payload['data'] ?? [];
switch ($eventType) {
case 'order.success':
// Update database order to paid
break;
case 'order.updated':
// Log refund in accounting records
break;
default:
break;
}
http_response_code(200);
echo json_encode(['status' => 'success']);4. Go (Golang)
In Go, verify the payload using crypto/rsa and crypto/sha256:
package main
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"log"
"net/http"
"os"
)
var amwalPublicKey *rsa.PublicKey
func init() {
pubKeyPEM := os.Getenv("AMWAL_PUBLIC_KEY")
block, _ := pem.Decode([]byte(pubKeyPEM))
if block == nil {
log.Fatal("Failed to parse PEM block containing public key")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
log.Fatalf("Failed to parse public key: %v", err)
}
amwalPublicKey = pub.(*rsa.PublicKey)
}
func verifySignature(rawBody []byte, signatureBase64 string) bool {
sigBytes, err := base64.StdEncoding.DecodeString(signatureBase64)
if err != nil {
return false
}
hashed := sha256.Sum256(rawBody)
err = rsa.VerifyPSS(amwalPublicKey, crypto.SHA256, hashed[:], sigBytes, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
})
return err == nil
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Signature")
if signature == "" {
http.Error(w, "Missing X-Signature header", http.StatusBadRequest)
return
}
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}
if !verifySignature(rawBody, signature) {
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
var payload struct {
EventType string `json:"event_type"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(rawBody, &payload); err != nil {
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
return
}
log.Printf("[Webhook] Received event: %s", payload.EventType)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
log.Println("Server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}Best Practices for Production
- Idempotency: Store received
data.id(Transaction UUID) in Redis or your database with a unique index. Discard duplicate webhook dispatches gracefully by returning HTTP200 OK. - Immediate Acknowledgment: Return HTTP
200 OKas soon as cryptographic validation succeeds. Run heavy business workflows (ERP sync, email confirmation, inventory reservations) in background workers. - Environment Isolation: Keep Sandbox and Production RSA public keys separated in environment variables (
AMWAL_PUBLIC_KEY_SANDBOXandAMWAL_PUBLIC_KEY_PROD).
