Integrate
Webhooks
Receive payment events, verify their signature, and handle retries and duplicates.
Fanak sends a signed POST when an intent is paid or canceled, even if the customer never returns.
Setting up
Section titled “Setting up”In the dashboard, Applications, your application:
- Webhook URL: one per application, public HTTPS, such as
https://shop.example.com/webhooks/fanak. No URL, no webhooks. - Webhook secret: roll it for a
whsec_...value, shown once. Keep it server-side.
Events
Section titled “Events”| Event | Sent when |
|---|---|
payment_intent.succeeded |
Paid. |
payment_intent.canceled |
Expired unpaid. |
No event for failed tries. A canceled intent can still get succeeded for a
late payment.
The request
Section titled “The request”Signed with whsec_your_webhook_secret; test against it:
POST /webhooks/fanak HTTP/1.1Content-Type: application/jsonFanak-Signature: a1f2685462b2f6db115d4ff60a2603a1b4037279750dcbe1defee95c04a98652
{"id":"9b2f6c1e-3d4a-4e8b-9f0a-7c5d2e1b8a64","event":"payment_intent.succeeded","data":{"uuid":"4d893346-bd5e-482f-8347-3f509a877f1a"}}| Field | Description |
|---|---|
id |
Delivery ID, kept on retries and resends. De-duplicate on it. |
event |
payment_intent.succeeded or payment_intent.canceled. |
data.uuid |
The intent. |
No amount or status: re-fetch the intent.
Handling a webhook
Section titled “Handling a webhook”-
Verify the signature.
Fanak-Signature= lowercase hex HMAC-SHA256 of the raw body, keyed with the whole secret (whsec_included). Compute before parsing, compare in constant time, reject with401. -
De-duplicate on
id. -
Answer
2xxwithin 10 seconds. Queue the work. -
Re-fetch the intent.
GET /payment-intents/{data.uuid}. Fulfil only onsucceededwith a matching amount; events can arrive out of order.
// routes/api.php (no CSRF check on API routes)Route::post('/webhooks/fanak', FanakWebhookController::class);<?php
namespace App\Http\Controllers;
use App\Jobs\SyncFanakPayment;use Illuminate\Http\Request;use Illuminate\Http\Response;use Illuminate\Support\Facades\Cache;
class FanakWebhookController{ public function __invoke(Request $request): Response { $expected = hash_hmac('sha256', $request->getContent(), config('services.fanak.webhook_secret'));
if (! hash_equals($expected, (string) $request->header('Fanak-Signature'))) { abort(401); }
// Retries and resends reuse the id: accept each delivery once. // A unique column in your database is more durable than the cache. if (Cache::add('fanak-webhook:'.$request->json('id'), true, now()->addDays(7))) { SyncFanakPayment::dispatch((string) $request->json('data.uuid')); }
return response()->noContent(); }}<?php
namespace App\Jobs;
use App\Models\Order;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;use Illuminate\Support\Facades\Http;
class SyncFanakPayment implements ShouldQueue{ use Queueable;
public function __construct(public string $intentUuid) {}
public function handle(): void { $intent = Http::withHeaders(['X-API-Key' => config('services.fanak.api_key')]) ->withToken(config('services.fanak.access_token')) ->acceptJson() ->get("https://pay.fanak.ly/api/v1/payment-intents/{$this->intentUuid}") ->throw() ->json('data');
$order = Order::where('payment_intent_uuid', $intent['uuid'])->firstOrFail();
if ($intent['status'] === 'succeeded' && $intent['amount'] === $order->amount) { $order->markAsPaid(); // Safe to call twice. } }}import { createServer } from 'node:http';import { createHmac, timingSafeEqual } from 'node:crypto';
const seen = new Set(); // Use your database in production.
createServer((req, res) => { if (req.method !== 'POST' || req.url !== '/webhooks/fanak') { res.writeHead(404).end(); return; }
const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); // The raw bytes: verify before parsing. const expected = Buffer.from(createHmac('sha256', process.env.FANAK_WEBHOOK_SECRET).update(body).digest('hex')); const received = Buffer.from(String(req.headers['fanak-signature'] ?? ''));
if (received.length !== expected.length || !timingSafeEqual(received, expected)) { res.writeHead(401).end(); return; }
const { id, data } = JSON.parse(body.toString('utf8')); res.writeHead(204).end(); // Answer first, work after.
if (seen.has(id)) return; seen.add(id); syncPayment(data.uuid).catch(console.error); // Use a job queue in production. });}).listen(3000);
async function syncPayment(uuid) { const response = await fetch(`https://pay.fanak.ly/api/v1/payment-intents/${uuid}`, { headers: { 'X-API-Key': process.env.FANAK_API_KEY, Authorization: `Bearer ${process.env.FANAK_ACCESS_TOKEN}`, Accept: 'application/json', }, });
if (!response.ok) { throw new Error(`Fanak answered ${response.status}`); }
const { data: intent } = await response.json();
if (intent.status === 'succeeded') { // Fulfil the order for intent.merchant_reference once, after checking intent.amount. }}# Send yourself a correctly signed test webhook.BODY='{"id":"9b2f6c1e-3d4a-4e8b-9f0a-7c5d2e1b8a64","event":"payment_intent.succeeded","data":{"uuid":"4d893346-bd5e-482f-8347-3f509a877f1a"}}'SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$FANAK_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i http://localhost:3000/webhooks/fanak \ -H "Content-Type: application/json" \ -H "Fanak-Signature: $SIGNATURE" \ -d "$BODY"Retries and delivery
Section titled “Retries and delivery”- Received:
2xxwithin 10 seconds, at the URL itself. Redirects aren’t followed and count as failures. - Retried on anything else, 9 tries over about a day: after 10 s, 1 min, 5 min, 30 min, 1 h, 3 h, 6 h, 12 h. Pending until then, showing the last answer; failed after the last try.
- Dashboard > Webhooks: every delivery with status, your response code and body, and tries. Owners and developers
can resend: current URL, same
id, current secret.
Deliveries can fail for good: also re-fetch on the customer’s return, and re-check orders unpaid after expires_at.
Secret rotation
Section titled “Secret rotation”After a roll, new deliveries use the new secret; retries queued earlier carry the old one. Accept both briefly.