Fawaterak Distributor — Admin
Sign in
Platform reference

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.

.NET 10ASP.NET CoreEF CoreSQL ServerFawaterak API v3.0.0
verified
Persist-then-ackEvents are durably stored before the 200, so none are lost.
alt_route
Hybrid routingpay_load.productId first, recorded reference mapping as fallback.
encrypted
Per-product signingEach product gets its own HMAC secret — isolation by design.
replay
Retries & dead-letterBackoff across 8 attempts, then dead-letter with one-click replay.

Fawaterak Webhook Distributor

CI

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

  1. Architecture
  2. Request lifecycle & states
  3. Routing model (hybrid)
  4. Inbound: the four Fawaterak webhooks
  5. Outbound: the envelope your products receive
  6. Delivery, retries & recovery
  7. Security model
  8. Data model
  9. Configuration reference
  10. API reference
  11. Hosting & deployment
  12. Local development
  13. Database & migrations
  14. Testing
  15. Project layout
  16. Sandbox runbook & open items
  17. 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:

  1. Primary — pay_load.productId. Fawaterak echoes back the pay_load you set at transaction creation (delivered as a JSON string). The distributor parses it and reads the productId (key name configurable via Distributor:PayLoadProductIdKey).
  2. Fallback — a pre-recorded mapping. If pay_load is absent (it can be null on the paid webhook), the distributor looks up a reference → productId mapping, trying both the event's transaction_id and transaction_key. Mappings are recorded by the proxy (/api/transactions) under the returned intent_key, or by pre-declare (/api/mappings).
  3. Otherwise — unrouted. The event is persisted as a dead-letter for inspection, never silently dropped.

⚠️ pay_load is 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 signed hashKey.


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}&currency={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 (default 5s), claiming up to Distributor:WorkerBatchSize (default 20) due deliveries per tick.
  • POSTs the signed envelope with a Distributor:DeliveryTimeout (default 15s) timeout.
  • 2xxdelivered. 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 return 401 (when RejectOnHashMismatch is 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, and AdminApiKey in environment variables or a secret store — never in source control. The shipped appsettings.json leaves them blank; if AdminApiKey is 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)WebhookNormalizer across all four v3 shapes + legacy IN + pending; HashVerifier per-type formulas (known-good and tampered); ProductRouter precedence (pay_load > mapping > unrouted, incl. transaction_key fallback and double-encoded pay_load); RetryPolicy schedule and exhaustion.
  • IntegrationWebApplicationFactory over in-memory SQLite, with a stubbed delivery endpoint and fake Fawaterak client: end-to-end ingest → signed delivery; idempotent dedupe; 401 on bad signature; form-urlencoded paid body; failed delivery → replay → success; proxy pay_load injection + forced webhookUrl + mapping record.

Tests run on SQLite (no Docker needed); a provider-conditional DateTimeOffset converter 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

  1. In Fawaterak Integrations, set all four webhook URLs to this service's /webhooks/{type}_json paths and copy your vendor API key + OAuth2 client credentials into config.
  2. Create a sandbox transaction via POST /api/transactions/{productId}, pay it, and confirm your product receives the signed envelope.
  3. This also confirms the three items the v3.0.0 docs leave implicit:
    • the exact pay_load field name accepted by createTransaction (echoed back as pay_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.

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