Fawaterak Webhook Distributor
One merchant account, one allowed webhook URL, many products — verified, normalized, and signed fan-out with persistence, idempotency, retries, and dead-lettering.
Fawaterak Webhook Distributor
One Fawaterak merchant account, one allowed webhook URL, many products.
Fawaterak permits a single webhook URL per merchant account. When you run several products
(web apps) on one account, they can't each register their own endpoint. This service is
that single URL: it receives every Fawaterak webhook, verifies it, figures out which product
the event belongs to (via a productId), and fans a normalized, signed copy out to each
product's own endpoint — with persistence, idempotency, retries, dead-lettering, and an
audit trail.
Stack: ASP.NET Core · .NET 10 · EF Core · SQL Server (remote). Targets Fawaterak API v3.0.0.
Table of contents
- Architecture
- Request lifecycle & states
- Routing model (hybrid)
- Inbound: the four Fawaterak webhooks
- Outbound: the envelope your products receive
- Delivery, retries & recovery
- Security model
- Data model
- Configuration reference
- API reference
- Hosting & deployment
- Local development
- Database & migrations
- Testing
- Project layout
- Sandbox runbook & open items
- Related docs
Architecture
┌──────────────────────────────────────────────┐
Fawaterak ──► │ POST /webhooks/{type}_json (Inbound) │
(4 hooks) │ paid | failed | cancel | refund │
│ 1. parse body (JSON or form-urlencoded) │
│ 2. verify hashKey (per-type HMAC formula) │
│ 3. normalize 4 shapes (+legacy IN) → │
│ CanonicalEvent │
│ 4. dedupe by idempotency key │
│ 5. resolve productId (pay_load → mapping) │
│ 6. persist, then ack 200 (persist-then-ack) │
└───────────────┬──────────────────────────────┘
│ (durable outbox row)
┌───────────────▼──────────────────────────────┐
│ DeliveryWorker (BackgroundService) │
│ poll due deliveries → POST signed envelope │
│ 2xx → delivered · else backoff retry │
│ after 8 attempts → dead-letter │
└───────────────────────────────────────────────┘
Admin ──► POST /api/products (assign productId + signing secret + API key)
or POST /api/mappings (pre-declare reference → productId)
Products ──► /api/gateway/* (mediator: every Fawaterak op, scoped per product,
X-Product-Key; we hold all Fawaterak credentials)
Ops ──► GET /api/events · /api/deliveries · POST /api/deliveries/{id}/replay
Three projects:
| Project | Responsibility |
|---|---|
Core |
Pure domain + logic — entities, CanonicalEvent, WebhookNormalizer, HashVerifier, ProductRouter, DeliveryEnvelope, OutboundSigner, RetryPolicy. No I/O. |
Infrastructure |
EF Core DbContext, the Fawaterak API client (OAuth2 + createTransaction), WebhookIngestionService, DeliveryWorker, registry/proxy services, DI wiring. |
Api |
Minimal-API host: endpoint groups, admin API-key filter, webhook body parsing, startup migrations. |
Request lifecycle & states
Persist-then-ack: the inbound event is durably stored before the 200 is returned,
so a fast acknowledgement to Fawaterak never loses an event even if a downstream product is
temporarily unreachable. Ingestion is idempotent — duplicates (same idempotency key)
are recognized and never re-enqueued.
Ingestion outcomes (returned in the webhook's 200 body, except where noted):
| Outcome | Meaning | HTTP |
|---|---|---|
accepted |
Verified, routed, delivery enqueued. | 200 |
duplicate |
Idempotency key already seen — no new work. | 200 |
unrouted |
Verified but no product resolved — stored as dead-letter. | 200 |
unknownproduct |
Resolved a productId with no active product. |
200 |
unverified |
hashKey failed verification — persisted for audit, never delivered. |
401* |
*401 only when Fawaterak:RejectOnHashMismatch = true (default); otherwise 200.
Idempotency key = {EventType}:{TransactionId|ReferenceId|InvoiceId}:{Status}, enforced
by a unique index.
Routing model (hybrid)
How the distributor decides which product an event belongs to:
- Primary —
pay_load.productId. Fawaterak echoes back thepay_loadyou set at transaction creation (delivered as a JSON string). The distributor parses it and reads theproductId(key name configurable viaDistributor:PayLoadProductIdKey). - Fallback — a pre-recorded mapping. If
pay_loadis absent (it can benullon the paid webhook), the distributor looks up areference → productIdmapping, trying both the event'stransaction_idandtransaction_key. Mappings are recorded by the proxy (/api/transactions) under the returnedintent_key, or by pre-declare (/api/mappings). - Otherwise — unrouted. The event is persisted as a dead-letter for inspection, never silently dropped.
⚠️
pay_loadis not part of any Fawaterak signature. It is safe to route on, but its contents must not be trusted as authenticated. All money-affecting fields are inside the signedhashKey.
Inbound: the four Fawaterak webhooks
Register all four in the Fawaterak dashboard (Integrations). Use the _json suffix so
paid/failed arrive as application/json; cancel/refund are always JSON. The service also
parses x-www-form-urlencoded bodies defensively.
| Event | Endpoint | Key fields | hashKey StringToSign |
|---|---|---|---|
Paid (paid/pending) |
/webhooks/paid_json |
transaction_id, transaction_key, payment_method, status, pay_load |
TransactionId={id}&TransactionKey={key}&PaymentMethod={method} |
| Failed | /webhooks/failed_json |
same as paid | same as paid |
| Cancel | /webhooks/cancel_json |
referenceId, paymentMethod |
referenceId={referenceId}&PaymentMethod={paymentMethod} |
| Refund | /webhooks/refund_json |
transactionId, amount, currency |
transactionId={id}&amount={amount}¤cy={currency} |
Every formula is HMAC-SHA256 (hex) keyed with your vendor API key
(Fawaterak:VendorApiKey). Legacy "IN" invoice payloads
(invoice_id/invoice_key/invoice_status) are still detected on the paid/failed
endpoints and verified with InvoiceId={id}&InvoiceKey={key}&PaymentMethod={method}.
status on the paid webhook may be pending for async methods (Fawry/Aman/Masary) —
the distributor forwards it as a distinct status; it is not collapsed into paid.
Outbound: the envelope your products receive
A single normalized, signed envelope — identical shape for every event type, so products never branch on Fawaterak's raw payloads:
{
"eventId": 42, // unique; products dedupe on this
"eventType": "paid", // paid | failed | cancel | refund
"productId": "prod_ab12cd34",
"transactionId": "28180", // paid/failed/refund
"transactionKey": "Asbv2zmnFfdUOOe", // paid/failed
"referenceId": "778586510", // cancel
"paymentMethod": "Fawry",
"status": "paid", // paid|pending|failed|canceled|refunded
"amount": 150.00, // refund
"currency": "EGP", // refund
"payLoad": { "order_id": "ORD-1001" }, // original merchant payload, parsed
"occurredAt": "2026-06-26T12:00:00+00:00"
}
Null fields are omitted — only fields relevant to the event type are present.
Signing headers on every delivery:
| Header | Value |
|---|---|
X-Distributor-Signature |
sha256=<hex> |
X-Distributor-Timestamp |
Unix seconds at signing time |
X-Distributor-Event-Id |
same as eventId |
Signature formula (per-product secret):
sha256=hex( HMAC_SHA256( signingSecret, timestamp + "." + rawBody ) )
Products must verify over the raw body bytes, compare in constant time, enforce a
timestamp window (replay protection), and dedupe on eventId (delivery is
at-least-once). Full product-side instructions and code samples (Node/PHP/Python/C#) live in
docs/product-side-guide.md.
Delivery, retries & recovery
A background DeliveryWorker drains a durable outbox table:
- Polls every
Distributor:WorkerPollInterval(default5s), claiming up toDistributor:WorkerBatchSize(default20) due deliveries per tick. - POSTs the signed envelope with a
Distributor:DeliveryTimeout(default15s) timeout. 2xx→delivered. Any other response or error → reschedule with backoff.
Backoff schedule (attempt → wait before next): 1m → 5m → 15m → 1h → 3h → 6h → 12h,
up to 8 attempts. After the cap the delivery becomes dead and is surfaced via
GET /api/deliveries?status=dead.
Delivery statuses: pending · delivered · dead.
Replay: POST /api/deliveries/{id}/replay resets a delivery to pending with
NextAttemptAt = now, so a recovered product can be re-driven without waiting for backoff.
Security model
Two independent trust boundaries:
- Inbound is authenticated by the Fawaterak
hashKey— HMAC-SHA256 with your vendor API key, verified per event type with a fixed-time comparison. Forged or invalid signatures return401(whenRejectOnHashMismatchis on) and are never delivered. - Outbound is authenticated by a per-product signing secret — a leak of one product's secret never affects another product, and never exposes the Fawaterak credentials.
Operational guidance:
- Serve everything over HTTPS; restrict
/api/*beyond the API key (IP allowlist / private network). Only Fawaterak needs the public/webhooks/*. - Keep
VendorApiKey,ClientSecret, andAdminApiKeyin environment variables or a secret store — never in source control. The shippedappsettings.jsonleaves them blank; ifAdminApiKeyis empty the admin API fails closed (503). - Rotate the admin key and product signing secrets periodically. A product's signing secret is returned once, at creation.
- Use least-privilege database credentials; back up the event/outbox tables.
Data model
| Entity | Key fields |
|---|---|
| Product | Id (assigned productId), Name, WebhookUrl, SigningSecret, IsActive, CreatedAt |
| TxnMapping | RefId (unique — transaction_id/transaction_key/legacy invoice_id), ProductId, Source (Proxy/PreDeclare), CreatedAt |
| InboundEvent | Id, EventType, Status, TransactionId, TransactionKey, ReferenceId, PaymentMethod, Amount, Currency, PayLoadRaw, RawBody, ContentType, HashVerified, IdempotencyKey (unique), ResolvedProductId, RoutingMethod, ReceivedAt |
| Delivery | Id, InboundEventId (FK), ProductId, TargetUrl, Status, AttemptCount, NextAttemptAt, LastStatusCode, LastError, CreatedAt, DeliveredAt |
Indexes: unique on TxnMapping.RefId and InboundEvent.IdempotencyKey; composite on
Delivery (Status, NextAttemptAt) for the worker poll.
Configuration reference
Bind via appsettings.json, environment variables (double-underscore syntax, e.g.
Fawaterak__VendorApiKey), or user-secrets. Never commit secrets.
Fawaterak section
| Key | Default | Purpose |
|---|---|---|
VendorApiKey |
— | Secret. HMAC key to verify every inbound hashKey. |
ApiBaseUrl |
https://app.fawaterk.com |
Fawaterak API base. |
TokenEndpoint |
/oauth/token |
OAuth2 client-credentials token endpoint (proxy). |
ClientId / ClientSecret |
— | Secret. OAuth2 credentials for createTransaction. |
CreateTransactionPath |
/api/v3/createTransaction |
v3 create-transaction path. |
RejectOnHashMismatch |
true |
true → reject invalid signatures with 401. |
Distributor section
| Key | Default | Purpose |
|---|---|---|
AdminApiKey |
— | Secret. Required in X-Api-Key on all /api/* routes. |
PublicBaseUrl |
— | This service's public URL; proxy uses it to set redirectionUrls.webhookUrl. |
PayLoadProductIdKey |
productId |
Key inside pay_load holding the productId. |
WorkerPollInterval |
00:00:05 |
Outbox poll interval. |
WorkerBatchSize |
20 |
Max deliveries claimed per tick. |
DeliveryTimeout |
00:00:15 |
Per-attempt outbound POST timeout. |
ConnectionStrings:Distributor |
— | SQL Server connection string (remote instance). |
API reference
Public — authenticated by Fawaterak hashKey
| Method | Path | Notes |
|---|---|---|
POST |
/webhooks/paid_json |
Paid / pending |
POST |
/webhooks/failed_json |
Failed |
POST |
/webhooks/cancel_json |
Cancellation |
POST |
/webhooks/refund_json |
Refund |
POST |
/webhooks/token_json |
Tokenization created-token (records saved card → product) |
GET |
/health |
Liveness → { "status": "ok" } |
Admin & proxy — require X-Api-Key
Create a product (assigns productId, signing secret, and gateway API key — secret and
key returned once):
POST /api/products
X-Api-Key: <admin-key>
Content-Type: application/json
{ "name": "My Shop", "webhookUrl": "https://myshop.example/fawaterak-hook" }
// 201 Created
{ "id": "prod_a1b2c3d4e5f6", "name": "My Shop",
"webhookUrl": "https://myshop.example/fawaterak-hook",
"signingSecret": "k7Yc…store-me-once", // verifies our deliveries to you
"apiKey": "pk_9f2…store-me-once" } // the product sends this as X-Product-Key
| Method | Path | Purpose |
|---|---|---|
GET |
/api/products |
List products |
GET |
/api/products/{id} |
Get one |
PATCH |
/api/products/{id} |
Update name / webhookUrl / isActive |
POST |
/api/products/{id}/rotate-key |
Issue a new gateway API key (old one stops working) |
DELETE |
/api/products/{id} |
Remove |
Proxy a transaction (injects pay_load.productId, forces redirectionUrls.webhookUrl,
records the fallback mapping):
POST /api/transactions/{productId}
X-Api-Key: <admin-key>
Content-Type: application/json
{ "cartTotal": 150.00, "cartItems": [ … ], "customer": { … } }
// 200 OK
{ "url": "https://app.fawaterk.com/checkout/…", "shortUrl": "https://…/c/abc",
"intentKey": "Asbv2zmnFfdUOOe" }
Errors: 404 (no active product), 502 (Fawaterak API error, with its body echoed).
Pre-declare a mapping · Audit · Replay:
POST /api/mappings { "refId": "Asbv2zmnFfdUOOe", "productId": "prod_…" }
GET /api/events?take=50 inbound audit (verified flag, routing, resolved product)
GET /api/deliveries?status=dead delivery status / dead-letter queue
POST /api/deliveries/{id}/replay re-queue immediately
Mediator gateway — products call us for everything (X-Product-Key)
Products never call Fawaterak directly and never hold Fawaterak credentials. They send their
own X-Product-Key (the apiKey from product creation) to /api/gateway/* and the
distributor attaches the central OAuth/vendor credential, injects routing on creation, and
enforces per-product isolation: every id-bearing or listing call is scoped to the calling
product. A request for another product's transaction/refund/token returns 403; list
endpoints return only the caller's rows. The productId comes from the key, so it can't be
spoofed. Responses relay Fawaterak's status and JSON; upstream errors return 502.
| Method | Path | Fawaterak op | Scoping |
|---|---|---|---|
GET |
/api/gateway/payment-methods |
getTrPaymentmethods | — |
POST |
/api/gateway/transactions |
createTransaction | injects pay_load.productId + webhookUrl |
POST |
/api/gateway/transactions/data |
getTransactionData | owns transaction_id/_key |
GET |
/api/gateway/transactions |
getTransactionsData | filtered to caller |
GET |
/api/gateway/refunds/types |
refund/refundTypes | — |
GET |
/api/gateway/refunds/reasons?lang= |
refund/refundReasons | — |
POST |
/api/gateway/refunds |
refund/create | owns refund_id (the transaction) |
GET |
/api/gateway/refunds |
refund/index | filtered to caller |
GET |
/api/gateway/refunds/{id} |
refund/details/ | owns the refund |
DELETE |
/api/gateway/refunds/{id} |
refund/delete | owns the refund |
POST |
/api/gateway/tokenization/card-screen |
createCardTokenScreen | links customerUniqueId |
POST |
/api/gateway/tokenization/pay |
createTokenizationPayRequest | owns the token |
POST |
/api/gateway/tokenization/pay/recurring |
…(recurring) | owns the token |
DELETE |
/api/gateway/tokenization/tokens |
deleteCustomerToken | owns the token |
GET /api/gateway/payment-methods
X-Product-Key: pk_9f2…
Ownership is learned automatically: the transaction proxy records the intent_key, and each
inbound webhook records the numeric transaction_id / referenceId — so by the time a
product asks about or refunds a transaction, the distributor already knows it owns it. Saved
cards are recorded from the POST /webhooks/token_json created-token webhook, scoped via
the customerUniqueId supplied when the card screen was created.
Hosting & deployment
The repo ships a single-service Compose file (the API only) and a multi-stage Dockerfile.
The database is a remote SQL Server you provision and manage — it is not bundled in the
container stack. Point the app at it with a connection string. Migrations are applied
automatically on startup in every environment except the test host.
export DISTRIBUTOR_CONNECTION_STRING="Server=sql.yourcompany.com,1433;Database=fawaterak_distributor;User Id=fawaterak;Password=<pw>;Encrypt=True;TrustServerCertificate=False"
export FAWATERAK_VENDOR_API_KEY="<your-vendor-api-key>"
export FAWATERAK_CLIENT_ID="<oauth-client-id>"
export FAWATERAK_CLIENT_SECRET="<oauth-client-secret>"
export DISTRIBUTOR_ADMIN_API_KEY="<a-long-random-secret>"
export DISTRIBUTOR_PUBLIC_BASE_URL="https://hooks.yourcompany.com"
docker compose up --build -d
docker compose logs -f api # watch startup + migrations
The login used in the connection string needs rights to create the schema (or run the migration SQL once yourself, then grant it data-reader/writer). The database itself must already exist.
Put the service behind TLS at your reverse proxy / load balancer. Expose /webhooks/*
publicly (Fawaterak must reach it); restrict /api/*.
Local development
Set a connection string to any reachable SQL Server (a local instance, LocalDB, or the
mcr.microsoft.com/mssql/server container — your choice; it is not part of the Compose
stack), then run the API:
export ConnectionStrings__Distributor="Server=localhost;Database=fawaterak_distributor;Trusted_Connection=True;TrustServerCertificate=True"
cd src/Fawaterak.Distributor.Api
dotnet run # http://localhost:5000 · auto-migrates
Register a product and watch it work:
ADMIN=dev-admin-key-change-me
curl -X POST http://localhost:5000/api/products \
-H "X-Api-Key: $ADMIN" -H "Content-Type: application/json" \
-d '{"name":"My Shop","webhookUrl":"https://myshop.example/fawaterak-hook"}'
# → { "id": "prod_…", "signingSecret": "…" }
Dev defaults (appsettings.Development.json) set a placeholder VendorApiKey and
AdminApiKey — change them. OpenAPI is mapped in Development.
Database & migrations
EF Core with the SQL Server provider (Microsoft.EntityFrameworkCore.SqlServer). The
initial migration lives under
src/Fawaterak.Distributor.Infrastructure/Persistence/Migrations.
# add a migration
dotnet ef migrations add <Name> \
--project src/Fawaterak.Distributor.Infrastructure \
--startup-project src/Fawaterak.Distributor.Api \
--output-dir Persistence/Migrations
# apply manually (startup also applies automatically outside the test env)
dotnet ef database update \
--project src/Fawaterak.Distributor.Infrastructure \
--startup-project src/Fawaterak.Distributor.Api
A design-time factory (DesignTimeDbContextFactory) lets dotnet ef build migrations
without starting the host or a live database.
Testing
dotnet test
27 tests, all green:
- Unit (Core) —
WebhookNormalizeracross all four v3 shapes + legacy IN +pending;HashVerifierper-type formulas (known-good and tampered);ProductRouterprecedence (pay_load > mapping > unrouted, incl.transaction_keyfallback and double-encoded pay_load);RetryPolicyschedule and exhaustion. - Integration —
WebApplicationFactoryover in-memory SQLite, with a stubbed delivery endpoint and fake Fawaterak client: end-to-end ingest → signed delivery; idempotent dedupe;401on bad signature; form-urlencoded paid body; failed delivery → replay → success; proxypay_loadinjection + forcedwebhookUrl+ mapping record.
Tests run on SQLite (no Docker needed); a provider-conditional
DateTimeOffsetconverter keeps comparisons translatable there while production stays native SQL Server.
Project layout
src/
Fawaterak.Distributor.Api/ Minimal API host, endpoint groups, admin auth, body parsing
Endpoints/ Webhook, Product, Transaction, Mapping, Ops endpoints
Security/AdminApiKeyFilter.cs X-Api-Key guard for /api/*
Fawaterak.Distributor.Core/ Pure domain + logic (no I/O)
Domain/ Product, TxnMapping, InboundEvent, Delivery, enums
Webhooks/ CanonicalEvent, WebhookNormalizer, HashVerifier
Routing/ProductRouter.cs Hybrid resolver
Outbound/ DeliveryEnvelope, OutboundSigner, RetryPolicy
Fawaterak.Distributor.Infrastructure/ EF Core, Fawaterak client, ingestion, delivery worker
Persistence/ DbContext, migrations, mapping lookup
Fawaterak/FawaterakClient.cs OAuth2 + createTransaction
Ingestion/WebhookIngestionService.cs Persist-then-ack pipeline
Delivery/DeliveryWorker.cs Outbox drainer
Registry/ Product / Mapping / TransactionProxy services
tests/Fawaterak.Distributor.Tests/ Unit + integration tests
Brochure/ Print-ready A4 integration brochure (HTML + PDF)
docs/product-side-guide.md Full product-side integration guide
docker-compose.yml · Dockerfile
Sandbox runbook & open items
- In Fawaterak Integrations, set all four webhook URLs to this service's
/webhooks/{type}_jsonpaths and copy your vendor API key + OAuth2 client credentials into config. - Create a sandbox transaction via
POST /api/transactions/{productId}, pay it, and confirm your product receives the signed envelope. - This also confirms the three items the v3.0.0 docs leave implicit:
- the exact
pay_loadfield name accepted bycreateTransaction(echoed back aspay_load); - Fawaterak's retry/timeout behavior on non-2xx (the design acks fast after a durable persist);
- the OAuth2 token endpoint URL/TTL for the client-credentials grant.
- the exact
Related docs
docs/product-side-guide.md— everything a downstream product needs: onboarding, making payments routable, the envelope, signature verification (Node/PHP/Python/C#), idempotency, per-event handling, security, testing, troubleshooting.Brochure/— print-ready A4 integration & API reference (HTML → PDF) plus export instructions.