Zorveq operates a central webhook router. When a payment event occurs in Helcim, Helcim notifies our router at a single URL, and the router immediately forwards a copy to every registered company endpoint — including yours.
What you need to do
POST requests.X-Webhook-Signature header on every incoming request.200 within 30 seconds.
Add a new URL to your website that will receive POST requests with a JSON body.
The path can be anything you like — for example:
Send this URL to Zorveq and we will register it in the router dashboard. Once active, every Helcim event will be forwarded here automatically.
Every forwarded request includes an X-Webhook-Signature header
containing an HMAC-SHA256 signature of the raw request body, signed with a
secret we generate for your endpoint.
Always verify this signature before processing the event. Reject any request where the signature is missing or does not match — this protects you from spoofed requests.
| Content-Type | application/json |
| X-Webhook-Signature | sha256=<hex digest> |
| User-Agent | Zorveq-Webhook-Router/1.0 |
<?php
// Retrieve the raw POST body BEFORE reading $_POST
$payload = file_get_contents('php://input');
// Your signing secret (store in an environment variable)
$secret = getenv('ZORVEQ_WEBHOOK_SECRET');
// Extract the signature header
$sigHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
// Compute expected signature
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
// Reject if signature does not match
if (!hash_equals($expected, $sigHeader)) {
http_response_code(401);
exit('Invalid signature');
}
// Decode the event
$event = json_decode($payload, true);
$eventType = $event['eventType'] ?? $event['type'] ?? 'unknown';
switch ($eventType) {
case 'payment.success':
// handle successful payment
$amount = $event['data']['amount'] ?? null;
$invoiceId = $event['data']['invoiceNumber'] ?? null;
// ... your logic here
break;
case 'payment.failed':
// handle failed payment
break;
case 'refund.success':
// handle refund
break;
default:
// unknown event — log and ignore
break;
}
// Respond 200 quickly so the router marks the delivery as successful
http_response_code(200);
echo json_encode(['status' => 'ok']);
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
// Your signing secret (store in an environment variable)
const SECRET = process.env.ZORVEQ_WEBHOOK_SECRET;
// IMPORTANT: use express.raw() so we get the unmodified body bytes for signature verification
router.post('/webhooks/inbound', express.raw({ type: 'application/json' }), (req, res) => {
const sigHeader = req.headers['x-webhook-signature'] || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(req.body) // req.body is a Buffer when using express.raw()
.digest('hex');
// Reject if signature does not match
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader))) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
const eventType = event.eventType || event.type || 'unknown';
switch (eventType) {
case 'payment.success':
// handle successful payment
console.log('Payment received:', event.data);
break;
case 'payment.failed':
// handle failed payment
break;
case 'refund.success':
// handle refund
break;
default:
console.log('Unhandled event type:', eventType);
}
// Respond 200 quickly
res.status(200).json({ status: 'ok' });
});
module.exports = router;
import hashlib
import hmac
import os
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
# Your signing secret (store in an environment variable)
SECRET = os.environ["ZORVEQ_WEBHOOK_SECRET"]
@app.post("/webhooks/inbound")
async def receive_webhook(
request: Request,
x_webhook_signature: str = Header(...),
):
body = await request.body()
# Compute expected signature
expected = "sha256=" + hmac.new(
SECRET.encode(), body, hashlib.sha256
).hexdigest()
# Reject if signature does not match
if not hmac.compare_digest(expected, x_webhook_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
event_type = event.get("eventType") or event.get("type") or "unknown"
if event_type == "payment.success":
# handle successful payment
pass
elif event_type == "payment.failed":
# handle failed payment
pass
elif event_type == "refund.success":
# handle refund
pass
# Respond 200 quickly
return {"status": "ok"}
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class HelcimWebhookController extends Controller
{
public function handle(Request $request)
{
// Read raw body before Laravel parses it
$payload = $request->getContent();
$secret = config('services.zorveq.webhook_secret'); // env('ZORVEQ_WEBHOOK_SECRET')
$sigHeader = $request->header('X-Webhook-Signature', '');
// Compute expected signature
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
// Reject if signature does not match
if (!hash_equals($expected, $sigHeader)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
$event = json_decode($payload, true);
$eventType = $event['eventType'] ?? $event['type'] ?? 'unknown';
match ($eventType) {
'payment.success' => $this->handlePaymentSuccess($event),
'payment.failed' => $this->handlePaymentFailed($event),
'refund.success' => $this->handleRefund($event),
default => Log::info('Unhandled Helcim event', ['type' => $eventType]),
};
return response()->json(['status' => 'ok']);
}
private function handlePaymentSuccess(array $event): void
{
$amount = $event['data']['amount'] ?? null;
$invoiceId = $event['data']['invoiceNumber'] ?? null;
// ... your logic here
}
private function handlePaymentFailed(array $event): void
{
// ... your logic here
}
private function handleRefund(array $event): void
{
// ... your logic here
}
}
// In routes/api.php:
// Route::post('/webhooks/inbound', [HelcimWebhookController::class, 'handle'])
// ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
The body is the original Helcim webhook JSON, forwarded verbatim. The structure follows Helcim's own webhook format. Key top-level fields:
| Field | Type | Description |
|---|---|---|
| eventType | string | Event name, e.g. payment.success |
| id | string | Unique event ID (useful for deduplication) |
| createdAt | string (ISO 8601) | Timestamp of the event |
| data | object | Event-specific payload (transaction details, amounts, etc.) |
Example payload:
{
"id": "evt_abc123",
"eventType": "payment.success",
"createdAt": "2026-06-11T14:30:00Z",
"data": {
"transactionId": "txn_xyz789",
"invoiceNumber": "INV-0042",
"amount": "150.00",
"currency": "CAD",
"cardType": "Visa",
"last4": "4242",
"customerName": "Jane Smith",
"customerEmail": "jane@example.com"
}
}
Refer to the
Helcim webhook documentation
for the full list of event types and their data shapes.
The router waits up to 30 seconds for your endpoint to respond.
If it times out or returns a 4xx/5xx
status, the delivery is marked failed and retried up to 3 times with exponential backoff.
Best practice
200 OK.id field to deduplicate — retries send the same payload.The most common cause is computing the HMAC against a parsed or re-serialised body instead of the raw bytes.
file_get_contents('php://input'), never $_POST.express.raw() middleware on this route, not express.json().$request->getContent(), not $request->all().localhost URLs will not work in production.POST requests and does not redirect to HTTPS (redirects lose the body).Exclude your webhook route from CSRF verification. Add it to $except in VerifyCsrfToken.php, or use the withoutMiddleware option in your route definition as shown in the Laravel example above.
Store the event id in your database and check for it before processing. If the ID already exists, return 200 OK immediately without re-processing. This makes your handler idempotent.
Need help?
Contact Zorveq at admin@zorveq.com to register your webhook URL, retrieve your signing secret, or troubleshoot delivery issues. Include your company name and the URL you want to register.