skills/payram/payram-helper-mcp-server/payram-webhook-integration

payram-webhook-integration

SKILL.md

PayRam Webhook Setup

First time with PayRam? See payram-setup to configure your server, API keys, and wallets.

Receive real-time notifications when payments confirm, fail, or payouts complete. Webhooks eliminate polling and enable event-driven architectures.

Webhook Flow

1. Payment status changes on-chain
2. PayRam sends POST to your webhook URL
3. Your handler verifies API-Key header
4. Process event (fulfill order, update DB)
5. Return 200 OK

Configuring Webhooks in PayRam

  1. Navigate to Settings → Webhooks in PayRam dashboard
  2. Add your endpoint URL: https://your-app.com/api/payram/webhook
  3. Copy the shared webhook secret
  4. Store secret as PAYRAM_WEBHOOK_SECRET in your .env

Webhook Payload

PayRam sends webhook requests with an API-Key header for verification:

POST https://your-domain.com/api/payram/webhook
Content-Type: application/json
API-Key: your-webhook-secret

{
  "reference_id": "ref_abc123",
  "invoice_id": "inv_xyz456",
  "customer_id": "cust_123",
  "customer_email": "customer@example.com",
  "status": "FILLED",
  "amount": 49.99,
  "filled_amount_in_usd": 49.99,
  "currency": "USD"
}

Critical: Verify the API-Key header matches your stored PAYRAM_WEBHOOK_SECRET before processing.

Payment Status Events

Status Meaning
OPEN Payment created, awaiting customer action
FILLED Payment completed successfully (exact amount paid)
PARTIALLY_FILLED Partial payment received (less than requested)
OVER_FILLED Overpayment received (more than requested)
CANCELLED Payment cancelled by customer or merchant
UNDEFINED Unknown status (future compatibility)

TypeScript Type Definitions

export type PayramWebhookStatus =
  | 'OPEN'
  | 'CANCELLED'
  | 'FILLED'
  | 'PARTIALLY_FILLED'
  | 'OVER_FILLED'
  | 'UNDEFINED';

export interface PayramWebhookPayload {
  reference_id: string;
  invoice_id?: string;
  customer_id?: string;
  customer_email?: string;
  status: PayramWebhookStatus;
  amount?: number;
  filled_amount_in_usd?: number;
  currency?: string;
  [key: string]: unknown;
}

Event Router

export async function handlePayramEvent(payload: PayramWebhookPayload) {
  switch (payload.status) {
    case 'FILLED':
      // Mark order as paid, deliver goods, send confirmation
      await fulfillOrder(payload.reference_id);
      break;
    case 'PARTIALLY_FILLED':
      // Update outstanding balance, notify finance team
      break;
    case 'OVER_FILLED':
      // Queue manual review or process refund
      break;
    case 'CANCELLED':
      // Release inventory, notify customer
      break;
    case 'OPEN':
      // Record payment acknowledgement
      break;
    default:
      // Log for investigation
      console.warn('Unknown status:', payload.status);
      break;
  }
}

Framework Handlers

Express.js

import express, { Request, Response } from 'express';
import crypto from 'crypto';

const router = express.Router();
router.use(express.json());

router.post('/api/payram/webhook', async (req: Request, res: Response) => {
  const sharedSecret = process.env.PAYRAM_WEBHOOK_SECRET;
  if (!sharedSecret) {
    return res.status(500).json({ error: 'webhook_not_configured' });
  }

  // Validate API-Key header (timing-safe comparison)
  const incomingKey = req.get('API-Key');
  if (!incomingKey) {
    return res.status(401).json({ error: 'invalid-webhook-key' });
  }

  const isValid = crypto.timingSafeEqual(Buffer.from(incomingKey), Buffer.from(sharedSecret));
  if (!isValid) {
    return res.status(401).json({ error: 'invalid-webhook-key' });
  }

  const payload = req.body;
  if (!payload?.reference_id || !payload?.status) {
    return res.status(400).json({ error: 'invalid-webhook-payload' });
  }

  try {
    await handlePayramEvent(payload);
    return res.json({ message: 'Webhook received successfully' });
  } catch (error) {
    console.error('Webhook handler error:', error);
    return res.status(500).json({ error: 'webhook_handler_error' });
  }
});

Next.js App Router

import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request: NextRequest) {
  const sharedSecret = process.env.PAYRAM_WEBHOOK_SECRET;
  if (!sharedSecret) {
    return NextResponse.json({ error: 'webhook_not_configured' }, { status: 500 });
  }

  const incomingKey = request.headers.get('API-Key');
  if (!incomingKey) {
    return NextResponse.json({ error: 'invalid-webhook-key' }, { status: 401 });
  }

  const isValid = crypto.timingSafeEqual(Buffer.from(incomingKey), Buffer.from(sharedSecret));
  if (!isValid) {
    return NextResponse.json({ error: 'invalid-webhook-key' }, { status: 401 });
  }

  const payload = await request.json();
  if (!payload.reference_id || !payload.status) {
    return NextResponse.json({ error: 'invalid-webhook-payload' }, { status: 400 });
  }

  try {
    await handlePayramEvent(payload);
    return NextResponse.json({ message: 'Webhook received successfully' });
  } catch (error) {
    return NextResponse.json({ error: 'webhook_handler_error' }, { status: 500 });
  }
}

FastAPI (Python)

import os
import hmac
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post('/api/payram/webhook')
async def payram_webhook(request: Request):
    shared_secret = os.getenv('PAYRAM_WEBHOOK_SECRET')
    if not shared_secret:
        raise HTTPException(status_code=500, detail='webhook_not_configured')

    incoming_key = request.headers.get('API-Key')
    if not incoming_key:
        raise HTTPException(status_code=401, detail='invalid-webhook-key')

    # Timing-safe comparison
    if not hmac.compare_digest(incoming_key, shared_secret):
        raise HTTPException(status_code=401, detail='invalid-webhook-key')

    payload = await request.json()
    if 'reference_id' not in payload or 'status' not in payload:
        raise HTTPException(status_code=400, detail='invalid-webhook-payload')

    await handle_payram_event(payload)
    return {'message': 'Webhook received successfully'}

Gin (Go)

import (
    "crypto/subtle"
    "net/http"
    "os"

    "github.com/gin-gonic/gin"
)

func handlePayramWebhook(c *gin.Context) {
    sharedSecret := os.Getenv("PAYRAM_WEBHOOK_SECRET")
    if sharedSecret == "" {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "webhook_not_configured"})
        return
    }

    incomingKey := c.GetHeader("API-Key")
    if incomingKey == "" {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid-webhook-key"})
        return
    }

    // Timing-safe comparison
    if subtle.ConstantTimeCompare([]byte(incomingKey), []byte(sharedSecret)) != 1 {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid-webhook-key"})
        return
    }

    var payload PayramWebhookPayload
    if err := c.ShouldBindJSON(&payload); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid-json-payload"})
        return
    }

    if err := handlePayramEvent(payload); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "webhook_handler_error"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "Webhook received successfully"})
}

Laravel (PHP)

class PayramWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $sharedSecret = env('PAYRAM_WEBHOOK_SECRET');
        if (!$sharedSecret) {
            return response()->json(['error' => 'webhook_not_configured'], 500);
        }

        $incomingKey = $request->header('API-Key');
        if (!$incomingKey) {
            return response()->json(['error' => 'invalid-webhook-key'], 401);
        }

        // Timing-safe comparison
        if (!hash_equals($sharedSecret, $incomingKey)) {
            return response()->json(['error' => 'invalid-webhook-key'], 401);
        }

        $payload = $request->json()->all();
        if (empty($payload['reference_id']) || empty($payload['status'])) {
            return response()->json(['error' => 'invalid-webhook-payload'], 400);
        }

        $this->router->handle($payload);
        return response()->json(['message' => 'Webhook received successfully']);
    }
}

Spring Boot (Java)

import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;

@PostMapping("/webhook")
public ResponseEntity<?> handleWebhook(
        @RequestBody Map<String, Object> payload,
        @RequestHeader(value = "API-Key", required = false) String apiKey) {

    String sharedSecret = System.getenv("PAYRAM_WEBHOOK_SECRET");
    if (sharedSecret == null || sharedSecret.isBlank()) {
        return ResponseEntity.status(500).body(Map.of("error", "webhook_not_configured"));
    }

    if (apiKey == null || apiKey.isBlank()) {
        return ResponseEntity.status(401).body(Map.of("error", "invalid-webhook-key"));
    }

    // Timing-safe comparison
    boolean isValid = MessageDigest.isEqual(
        apiKey.getBytes(StandardCharsets.UTF_8),
        sharedSecret.getBytes(StandardCharsets.UTF_8)
    );
    if (!isValid) {
        return ResponseEntity.status(401).body(Map.of("error", "invalid-webhook-key"));
    }

    if (!payload.containsKey("reference_id") || !payload.containsKey("status")) {
        return ResponseEntity.status(400).body(Map.of("error", "invalid-webhook-payload"));
    }

    router.handle(payload);
    return ResponseEntity.ok(Map.of("message", "Webhook received successfully"));
}

Best Practices

Idempotency: Handle duplicate deliveries gracefully — check if already processed before fulfilling:

async function handleFilledPayment(payload: PayramWebhookPayload) {
  const existing = await db.payments.findUnique({
    where: { payramReferenceId: payload.reference_id },
  });
  if (existing && existing.status === 'completed') {
    return; // Already processed, safe to skip
  }
  await db.payments.update({
    where: { payramReferenceId: payload.reference_id },
    data: { status: 'completed', paidAt: new Date() },
  });
  await fulfillOrder(payload.customer_id, payload.reference_id);
}

Quick Response: Return 200 immediately, process asynchronously. PayRam retries on timeout.

Retry Handling: If you return 5xx, PayRam retries with exponential backoff. Return 200 for permanent failures to prevent retries.

Database Transactions: Use transactions for critical operations to ensure consistency.

Testing Webhooks

cURL Test

curl -X POST http://localhost:3000/api/payram/webhook \
  -H "Content-Type: application/json" \
  -H "API-Key: $PAYRAM_WEBHOOK_SECRET" \
  -d '{
    "reference_id": "ref_test_001",
    "status": "FILLED",
    "customer_id": "cust_123",
    "amount": 49.99,
    "filled_amount_in_usd": 49.99,
    "currency": "USD"
  }'

MCP Server Tools

Tool Purpose
generate_webhook_handler Framework-specific handler code
generate_webhook_event_router Fan-out router for multiple event types
generate_mock_webhook_event Test payloads for each event type

Environment Variables

PAYRAM_WEBHOOK_SECRET=your-webhook-secret-from-dashboard

All PayRam Skills

Skill What it covers
payram-setup Server config, API keys, wallet setup, connectivity test
payram-agent-onboarding Agent onboarding — CLI-only deployment for AI agents, no web UI
payram-analytics Analytics dashboards, reports, and payment insights via MCP tools
payram-crypto-payments Architecture overview, why PayRam, MCP tools
payram-payment-integration Quick-start payment integration guide
payram-self-hosted-payment-gateway Deploy and own your payment infrastructure
payram-checkout-integration Checkout flow with SDK + HTTP for 6 frameworks
payram-webhook-integration Webhook handlers for Express, Next.js, FastAPI, Gin, Laravel, Spring Boot
payram-stablecoin-payments USDT/USDC acceptance across EVM chains and Tron
payram-bitcoin-payments BTC with HD wallet derivation and mobile signing
payram-payouts Send crypto payouts and manage referral programs
payram-no-kyc-crypto-payments No-KYC, no-signup, permissionless payment acceptance

Support

Need help? Message the PayRam team on Telegram: @PayRamChat

Weekly Installs
13
GitHub Stars
29
First Seen
Feb 6, 2026
Installed on
claude-code12
codex9
mcpjam8
iflow-cli8
windsurf8
zencoder8