Product-Side Integration Guide
Everything your product needs to receive, verify, and act on events. You talk to the Distributor, never to Fawaterak — and only ever see your own data.
Product-Side Integration Guide
How your product (a web app running behind the Fawaterak Webhook Distributor) integrates with it. This document covers only your side: what you receive, how to verify it, how to respond, and how to create payments so events route back to you.
You never talk to Fawaterak's webhook directly — the Distributor is the single registered webhook and forwards a normalized, signed copy of every event that belongs to you.
1. What the platform team gives you
When your product is registered with the Distributor, you receive these values out-of-band. Store them securely (secret manager / environment variables — never in source control):
| Value | Example | Use |
|---|---|---|
apiKey |
pk_9f2… (shown once) |
Your gateway key. Send it as X-Product-Key on every call you make to the Distributor. |
signingSecret |
k7Yc… (shown once) |
Verifies the signature on every webhook the Distributor sends you. |
productId |
prod_a1b2c3d4e5f6 |
Identifies your product (for reference; the gateway derives it from your key). |
| Distributor base URL | https://hooks.yourcompany.com |
The single host you call for everything. |
The
apiKeyandsigningSecretare returned only at creation time. If you lose the key, ask the platform team to rotate it (POST /api/products/{id}/rotate-key) — you cannot read it back. Rotating invalidates the old key.
You also tell the platform team your webhookUrl — the public HTTPS endpoint on your
side that will receive events (e.g. https://myshop.example/fawaterak-hook).
2. You talk to us, not to Fawaterak
You never call Fawaterak directly and you never hold Fawaterak credentials. The
Distributor is your single gateway: it holds the shared merchant account, attaches the right
credential to each call, and isolates your data — you can only ever see or act on your
own transactions, refunds, and saved cards. Authenticate every call with your
X-Product-Key; the gateway figures out your productId from the key, so you never pass it
and it can't be spoofed.
Create a payment
POST {DistributorBaseUrl}/api/gateway/transactions
X-Product-Key: pk_9f2…
Content-Type: application/json
{
"cartTotal": 150.00,
"cartItems": [
{ "name": "Pro plan", "price": 150.00, "quantity": 1 }
],
"customer": { "first_name": "Sara", "email": "sara@example.com" }
// add any pay_load fields you want echoed back; productId is added for you
}
// 200 OK
{
"url": "https://app.fawaterk.com/checkout/…", // redirect the buyer here
"shortUrl": "https://…/c/abc",
"intentKey": "Asbv2zmnFfdUOOe" // = transaction_key
}
Redirect the customer to url (or shortUrl). You'll receive the result later as a webhook
(section 3). Routing is automatic — the gateway tagged the payment with your product, so you
do not need to pre-declare anything or set pay_load yourself.
Everything else you can do through the gateway
All of these take X-Product-Key and are scoped to you — a request for a transaction,
refund, or token that isn't yours returns 403, and lists return only your rows:
| Call | Purpose |
|---|---|
GET /api/gateway/payment-methods |
List the enabled payment methods. |
POST /api/gateway/transactions/data |
Get one transaction's status ({ "transaction_key": "…" } or transaction_id). |
GET /api/gateway/transactions |
List your transactions. |
GET /api/gateway/refunds/types · …/reasons?lang=ar |
Refund lookups. |
POST /api/gateway/refunds |
Submit a refund (refund_type, refund_id, refundable_amount, reason). |
GET /api/gateway/refunds · GET /api/gateway/refunds/{id} |
List / inspect your refunds. |
DELETE /api/gateway/refunds/{id} |
Cancel a pending refund. |
POST /api/gateway/tokenization/card-screen |
Start saving a card (pass a customerUniqueId). |
POST /api/gateway/tokenization/pay · …/pay/recurring |
Charge a saved-card token. |
DELETE /api/gateway/tokenization/tokens |
Delete a saved-card token. |
Each response relays Fawaterak's own status code and JSON body unchanged. A 403 means the
resource isn't yours; a 502 means Fawaterak returned an error (its body is echoed under
fawaterak).
Saved cards. Call
card-screenwith acustomerUniqueIdyou choose; open the returned URL for the customer. When the card is saved, the Distributor records the resulting token against your product, so latertokenization/paycalls with that token are authorized only for you.
3. The webhook you will receive
The Distributor sends an HTTP POST to your webhookUrl with
Content-Type: application/json and this stable envelope — identical shape for every
event type, so you never branch on Fawaterak's raw payloads:
{
"eventId": 42, // unique per event — dedupe on this
"eventType": "paid", // paid | failed | cancel | refund
"productId": "prod_a1b2c3d4e5f6",
"transactionId": "28180", // present for paid/failed/refund
"transactionKey": "Asbv2zmnFfdUOOe", // present for paid/failed
"referenceId": "778586510", // present for cancel
"paymentMethod": "Fawry",
"status": "paid", // see status table below
"amount": 150.00, // present for refund
"currency": "EGP", // present for refund
"payLoad": { "order_id": "ORD-1001" }, // your original pay_load, parsed
"occurredAt": "2026-06-26T12:00:00+00:00"
}
Null fields are omitted. Only fields relevant to the event type are present (e.g.
referenceIdappears oncancel;amount/currencyonrefund). Always null-check optional fields.
Headers
| Header | Meaning |
|---|---|
X-Distributor-Signature |
sha256=<hex> — HMAC of the body (verify this). |
X-Distributor-Timestamp |
Unix time (seconds) when the delivery was signed. |
X-Distributor-Event-Id |
Same as eventId — convenient for dedupe/logging. |
eventType and status values
eventType |
Typical status |
What it means |
|---|---|---|
paid |
paid |
Payment captured — fulfil the order. |
paid |
pending |
Async method (Fawry/Aman/Masary) — reference issued, not yet paid. Do not fulfil. |
failed |
failed |
Payment attempt failed (declined, gateway error). |
cancel |
canceled |
Async reference expired or was canceled. |
refund |
refunded |
A refund was processed (amount/currency set). |
4. Verify every webhook
Never act on a webhook before verifying its signature. The signature proves it came from the Distributor and (with the timestamp) blocks replays.
Formula:
expected = "sha256=" + hex( HMAC_SHA256( signingSecret, timestamp + "." + rawBody ) )
Rules:
- Compute over the raw request body bytes — not a re-serialized object (re-serializing changes whitespace/key order and breaks the HMAC).
- Compare in constant time.
- Reject if
|now - timestamp|exceeds your tolerance (e.g. 5 minutes) to stop replays.
Node.js / Express
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.DISTRIBUTOR_SIGNING_SECRET;
const app = express();
// IMPORTANT: capture the raw body for HMAC
app.post("/fawaterak-hook",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
const sig = req.get("X-Distributor-Signature") || "";
const ts = Number(req.get("X-Distributor-Timestamp") || 0);
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401);
const expected = "sha256=" +
crypto.createHmac("sha256", SECRET).update(`${ts}.${raw}`).digest("hex");
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(raw);
if (alreadyProcessed(event.eventId)) return res.sendStatus(200); // idempotent
handleEvent(event); // your logic
return res.sendStatus(200); // ack fast
});
PHP (Laravel controller)
public function handle(Request $request)
{
$raw = $request->getContent();
$sig = $request->header('X-Distributor-Signature', '');
$ts = (int) $request->header('X-Distributor-Timestamp', 0);
if (abs(time() - $ts) > 300) abort(401);
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, config('services.distributor.secret'));
if (! hash_equals($expected, $sig)) abort(401);
$event = json_decode($raw, true);
if ($this->alreadyProcessed($event['eventId'])) return response('', 200);
$this->handleEvent($event);
return response('', 200);
}
Python (Flask)
import hmac, hashlib, time
from flask import request, abort
SECRET = os.environ["DISTRIBUTOR_SIGNING_SECRET"].encode()
@app.post("/fawaterak-hook")
def hook():
raw = request.get_data() # raw bytes
sig = request.headers.get("X-Distributor-Signature", "")
ts = int(request.headers.get("X-Distributor-Timestamp", "0"))
if abs(time.time() - ts) > 300:
abort(401)
mac = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(f"sha256={mac}", sig):
abort(401)
event = request.get_json()
if already_processed(event["eventId"]):
return "", 200
handle_event(event)
return "", 200
C# / ASP.NET Core (minimal API)
app.MapPost("/fawaterak-hook", async (HttpRequest req) =>
{
using var reader = new StreamReader(req.Body);
var raw = await reader.ReadToEndAsync();
var sig = req.Headers["X-Distributor-Signature"].ToString();
var ts = long.Parse(req.Headers["X-Distributor-Timestamp"].ToString());
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
return Results.Unauthorized();
var mac = Convert.ToHexStringLower(System.Security.Cryptography.HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{ts}.{raw}")));
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(sig), Encoding.UTF8.GetBytes($"sha256={mac}")))
return Results.Unauthorized();
var ev = JsonSerializer.Deserialize<JsonElement>(raw);
var eventId = ev.GetProperty("eventId").GetInt64();
if (AlreadyProcessed(eventId)) return Results.Ok();
HandleEvent(ev);
return Results.Ok();
});
5. Respond correctly
- Return
2xxquickly (ideally after you've persisted the event, before heavy work). Treat your handler as "accept & enqueue", then process asynchronously. - Any non-2xx (or a timeout) triggers redelivery. Deliveries are at-least-once:
the same event may arrive more than once, so deduplicate on
eventIdand make your side-effects idempotent. - Redelivery schedule (so you know what to expect): retries at roughly 1 min → 5 min → 15 min → 1 h → 3 h → 6 h → 12 h, up to 8 attempts. After that the delivery is dead-lettered and the platform team can replay it once your endpoint is healthy.
6. Handle each event type
paid + paid → mark the order paid, fulfil, send receipt
paid + pending → record "awaiting payment"; DO NOT fulfil yet (a paid event follows)
failed → mark the attempt failed; let the customer retry
cancel → release any hold / expire the pending order
refund → reverse fulfilment / update accounting (amount, currency provided)
Reconcile against your own records. Before granting value, re-check the amount and
status against the order you created. Treat payLoad contents as untrusted input —
it is not covered by Fawaterak's signature; use it only as a correlation hint
(e.g. your order_id), never as proof of payment.
7. Security checklist (your side)
- Serve
webhookUrlover HTTPS only. - Verify the signature on every request before any side effect.
- Enforce a timestamp window (≈5 min) to reject replays.
- Compute the HMAC over the raw body and compare in constant time.
- Deduplicate on
eventId; make handlers idempotent (at-least-once delivery). - Keep
signingSecretand your API key in a secret store, never in code. - Don't trust
payLoad; reconcile money-affecting fields against your own order. - Return
2xxfast; do heavy work asynchronously. - Rotate the
signingSecretperiodically (coordinate with the platform team).
8. Test your integration locally
Simulate a delivery with a correctly-signed request. This bash snippet signs the body exactly like the Distributor does:
SECRET="your-signing-secret"
TS=$(date +%s)
BODY='{"eventId":1,"eventType":"paid","productId":"prod_test","transactionId":"28180","paymentMethod":"Fawry","status":"paid","payLoad":{"order_id":"ORD-1001"},"occurredAt":"2026-06-26T12:00:00+00:00"}'
SIG="sha256=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -i -X POST http://localhost:3000/fawaterak-hook \
-H "Content-Type: application/json" \
-H "X-Distributor-Signature: $SIG" \
-H "X-Distributor-Timestamp: $TS" \
-H "X-Distributor-Event-Id: 1" \
--data "$BODY"
Expect your endpoint to return 200. Flip a character in SECRET or BODY and confirm
it returns 401 — that proves your verification works.
9. Troubleshooting
| Symptom | Likely cause |
|---|---|
| You never receive events | Payment wasn't created through POST /api/gateway/transactions (so it wasn't tagged with your product), or webhookUrl is wrong/unreachable. |
| Signature check always fails | You're hashing a re-serialized body instead of the raw bytes, or the timestamp isn't included as "{ts}.{body}", or wrong signingSecret. |
401 on valid requests |
Timestamp window too tight, or clock skew between servers — widen the window or sync NTP. |
| Same order processed twice | Not deduping on eventId — delivery is at-least-once. |
pending treated as paid |
Don't fulfil on status: pending; wait for the paid event. |
| Events stopped after downstream outage | Deliveries dead-lettered after 8 attempts — ask the platform team to replay them. |
For credentials, replays, or production support, contact the platform team that operates the Distributor.