Amwal Tech logoDocs

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

  1. Idempotency: Store received data.id (Transaction UUID) in Redis or your database with a unique index. Discard duplicate webhook dispatches gracefully by returning HTTP 200 OK.
  2. Immediate Acknowledgment: Return HTTP 200 OK as soon as cryptographic validation succeeds. Run heavy business workflows (ERP sync, email confirmation, inventory reservations) in background workers.
  3. Environment Isolation: Keep Sandbox and Production RSA public keys separated in environment variables (AMWAL_PUBLIC_KEY_SANDBOX and AMWAL_PUBLIC_KEY_PROD).

On this page