Skip to content
FanakDocs
WebsiteDashboard

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.

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.
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.

Signed with whsec_your_webhook_secret; test against it:

POST /webhooks/fanak HTTP/1.1
Content-Type: application/json
Fanak-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.

  1. 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 with 401.

  2. De-duplicate on id.

  3. Answer 2xx within 10 seconds. Queue the work.

  4. Re-fetch the intent. GET /payment-intents/{data.uuid}. Fulfil only on succeeded with 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.
}
}
}
  • Received: 2xx within 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.

After a roll, new deliveries use the new secret; retries queued earlier carry the old one. Accept both briefly.