payram-webhook-integration
PayRam Webhook Setup
First time with PayRam? See
payram-setupto 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
- Navigate to Settings → Webhooks in PayRam dashboard
- Add your endpoint URL:
https://your-app.com/api/payram/webhook - Copy the shared webhook secret
- Store secret as
PAYRAM_WEBHOOK_SECRETin 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
- Website: https://payram.com
- GitHub: https://github.com/PayRam
- MCP Server: https://github.com/payram/payram-mcp