Marketplace Webhooks
How inbound provider webhooks reach a marketplace app through Brainerce, the three receiver routes, how each one is authenticated, and what your response can trigger.
This document explains how inbound webhooks work for marketplace apps in Brainerce: how external providers reach your app, how your app authenticates to the platform, and the trust boundaries between each layer.
It's targeted at developers building payment gateway, shipping, and similar apps for the Brainerce marketplace.
The high-level flow
Every inbound webhook goes through the same shape:
External provider Brainerce core Your app
(Stripe / PayPal / etc.) (backend) (stateless HTTP service)
│ │ │
│ 1. POST the provider-signed / │ │
│ core-signed webhook URL │ │
│ ─────────────────────────────► │ │
│ │ │
│ │ 2. Authenticate the URL │
│ │ (provider sig OR URL HMAC) │
│ │ │
│ │ 3. Forward via AppProxyService │
│ │ (Ed25519-signed outbound) │
│ │ ──────────────────────────────► │
│ │ │
│ │ │ 4. Verify
│ │ │ provider sig /
│ │ │ call provider API
│ │ │
│ │ 5. Normalized response │
│ │ ◄────────────────────────────── │
│ │ │
│ │ 6. Apply side-effects │
│ │ (queue, config, invoice) │
│ │ │
│ 7. 200 OK (synchronous reply) │ │
│ ◄────────────────────────────── │Critical: Brainerce core is the HTTP-facing endpoint for inbound provider webhooks. Your app never receives provider webhooks directly; the core forwards them to your app. This is by design:
- Single stable URL per installation. Providers always hit
${BACKEND_URL}/api/apps/webhooks/..., never per-app URLs. When your app is redeployed, re-hosted, or rolled back, providers don't need to be reconfigured. - Your app stays stateless. You don't need a database, a KV store, or any persistent secrets. Every call from core arrives with fresh
configandsecretsin the request body, decrypted fromAppInstallation.encryptedSecrets. - Your app doesn't need a public URL. The core calls your app via the app-proxy service, which can route to internal addresses (
localhost:4022, Docker network hostnames, internal Kubernetes services). Providers never reach your app directly.
Three routes, two trust models
The core server has three webhook receiver routes (plus one GET handshake on Route 3). Two are keyed by installationId and you pick between them based on how your provider authenticates its webhooks; the third has no installationId in the URL at all, for providers whose events don't arrive per-installation in the first place.
Route 1: Unsigned (provider-signed webhooks)
POST /api/apps/webhooks/:installationId/:path(*)Use this when your provider signs its own webhooks with a scheme you can verify, such as Stripe signatures, PayPal's verify-webhook-signature API, Shopify HMAC-SHA256, or Clerk/Svix. The core doesn't verify anything on this route (it's @Public()), because it doesn't know how to verify arbitrary provider schemes. Your app is responsible for verifying the provider's signature before returning a normalized response.
The core forwards the provider's original body under originalPayload (the parsed JSON or form body), the exact bytes the provider sent under originalRawBody (use this when the provider signs a digest of the raw body), and the original headers under originalHeaders. originalHeaders carries a fixed list of known signature headers (stripe-signature, x-shopify-*, x-wc-webhook-*, x-tiktok-signature, paypal-*, shippo-auth-signature, x-hmac-signature, content-type) plus any other header that starts with x-, stripe-, paypal- or shippo- and has signature or hmac in its name. A provider that signs under a name outside those rules is declared in your manifest instead of changing the router: list up to 12 header names in incomingWebhooks[].forwardHeaders and they are forwarded for that path only (names are lowercase letters, digits and hyphens; authorization, proxy-authorization, cookie, set-cookie, host, forwarded, via, connection and x-forwarded-* are never forwarded, whatever the manifest asks). Nothing else is forwarded. A :path containing .. returns 404, and the route answers { "received": true, "processed": <your response's processed flag> } to the provider. A response with processed: true is cached for 5 minutes per installation, path and body, so an identical replay is answered without calling your app again:
// apps/<provider>-payments/src/handlers/webhook.handler.ts
export async function paymentWebhook(req: Request, res: Response): Promise<void> {
const body = req.body as InboundWebhookRequest;
const payload = body.originalPayload as Record<string, unknown>;
const headers = body.originalHeaders as Record<string, string>;
// Verify provider signature HERE using body.secrets (decrypted by core)
const verified = verifyProviderSignature(payload, headers, body.secrets);
if (!verified) {
res.json({ received: true, processed: false });
return;
}
// Return normalized event; core enqueues it for processing
res.json({
received: true,
processed: true,
event: { type: 'payment.succeeded', payment: { ... } },
});
}If your provider cannot sign its webhooks with a secret you hold, this route alone is not secure, because any attacker who learns an installationId can POST a fake webhook. Use Route 2 instead.
Route 2: Signed (HMAC URL for providers that don't sign)
POST /api/apps/webhooks-signed/:installationId/:expires/:hmac/:path(*)Use this when your provider cannot sign webhooks: providers that only call a fixed notify URL, legacy gateways, custom integrations where you control both ends but don't want to implement HMAC headers. The URL is a stateless signed token, not a lookup token:
installationId: public; used for routing and rate-limit keyingexpires: Unix seconds timestamp; the URL becomes useless after thishmac:HMAC-SHA256(perInstallationKey, "${installationId}|${expires}|${path}")hex-encodedpath: the marketplace app's webhook handler path
The HMAC key never appears in any URL. It lives in AppInstallation.encryptedSecrets.webhookHmacKeyV1 and is used exclusively inside the backend process to sign URLs (at create-intent time) and verify them (at webhook receipt). Reverse-proxy access logs that capture the URL reveal only a signature, not a secret, so an attacker who reads logs cannot forge new URLs.
Verification is stateless: the webhook-router reads the HMAC key from the installation's encrypted secrets, recomputes the expected HMAC from the URL components, and compares with crypto.timingSafeEqual. Any mismatch or expired URL returns 404 (no installation-existence leak).
How the URL is minted
The backend's PaymentService.mintSignedWebhookUrl() is called at create-intent time (in createPaymentIntent), before the payment provider app is invoked:
// apps/backend/src/modules/payment/payment.service.ts
const invoiceNotifyUrl = await this.mintSignedWebhookUrl(
installation,
'invoice-notify',
backendUrl
);
metadata.invoiceNotifyUrl = invoiceNotifyUrl;
// ... then call paymentProxy.createPaymentIntent(installation, { ..., metadata })The full signed URL is passed to your app via body.metadata.invoiceNotifyUrl. Your app just reads it and hands it verbatim to the provider, with no signing code on your side:
// apps/<provider>-payments/src/handlers/payment.handler.ts
const signedInvoiceNotifyUrl = body.metadata?.invoiceNotifyUrl as string | undefined;
if (signedInvoiceNotifyUrl) {
requestBody.invoiceNotifyUrl = signedInvoiceNotifyUrl;
} else {
// Missing URL → log and skip. Do NOT fall back to an unsigned endpoint.
console.warn('[provider] metadata.invoiceNotifyUrl missing — invoice-notify disabled');
}Never construct a legacy unsigned URL as a fallback, because that silently regresses security. If the URL is missing, the app should log and omit the callback entirely so the misconfiguration is visible.
Defense-in-depth layers on the signed route
The signed URL is the primary auth, but six additional layers reduce the blast radius of any residual leak:
- Short expiry (1 hour). Set in
SIGNED_WEBHOOK_MAX_AGE_SECONDS. Captured URLs become useless quickly. - Per-installation rate limiting (30,000 req/min, i.e. 500/sec).
InstallationThrottlerGuardkeys the bucket by:installationIdfrom the URL (not by IP), so one busy installation cannot exhaust the bucket for others behind the same egress. The limit is a DoS safety valve, not a per-plan quota: the ceiling is set so that no realistic store, even a Black-Friday-scale retailer at ~2000 orders/min with several webhook events per order, ever reaches it, while a volumetric attacker gets 429 well before the DB pool saturates. Plan-based quotas belong in a separate central rate-limit service, not here. - Idempotency lock on invoice writes.
PaymentService.applyInvoiceForInstallationrefuses to overwrite an existinginvoiceUrl. First write wins, so a captured URL replayed after the legitimate webhook succeeds fails with 409 Conflict. Same-data replays are silent no-ops. - Tenant-scope check. Even if a URL is leaked, the write target is constrained to payments belonging to the calling installation. Cross-installation attempts throw
ForbiddenException. - Log sanitization.
RedactingLoggerstrips the HMAC segment from any log line containing/webhooks-signed/, so the signature never appears in backend logs even if someone passesreq.urltologger.log. Configure your reverse proxy (nginx, Cloudflare, ALB) to similarly redact/api/apps/webhooks-signed/*path segments. - Automatic HMAC key rotation (90 days).
ensureWebhookHmacKeyrotates the per-installation key afterSIGNED_WEBHOOK_HMAC_KEY_ROTATION_AFTER_SECONDS. Existing in-flight URLs signed with the old key keep working until they expire (≤ 1 hour later), so rotation is non-disruptive. For suspected-compromise, callPaymentService.rotateWebhookHmacKeyImmediately(installationId)to force-rotate.
Route 3: Global (platform-level account, no installationId)
POST /api/apps/webhooks-global/:appSlug/:path(*)Route 1 assumes every provider event names an installationId in the URL you registered with the provider. That assumption breaks for a provider whose events fire on a platform-level account shared across every store, rather than a per-merchant one: for example, Stripe Connect's TEST-mode Checkout Sessions run on the platform's own Stripe account, not on each store's connected sub-account (see apps/stripe-payments/src/handlers/webhook.handler.ts and oauth.handler.ts, which read and write config.stripeTestAccountId / config.stripeLiveAccountId). PayPal's app-level webhooks have the same shape: they fire once for the platform's PayPal app, not once per installation.
Use this route when your provider cannot address a webhook to one installation, because the event originates from an account the platform itself owns or shares. If your provider signs its callbacks per-merchant against a per-installation account (the normal case), use Route 1 instead: Route 3 exists specifically for the shared-account exception.
Why not just use a per-installation URL anyway? Because the URL would need re-registering with the provider on every reinstall (the installationId changes each time an app is reinstalled), and a shared platform-level account has nowhere per-installation to put one. Route 3 is registered once, ever, per environment, and the core resolves which installation an incoming event belongs to by reading the event content, not the URL:
POST /api/apps/webhooks-global/stripe-payments/paymentresolveInstallationForEvent (in webhook-router.controller.ts) tries, in order. Every lookup resolves to an installation of the app named in :appSlug with status INSTALLED; an uninstalled or suspended installation never matches.
-
Manifest-declared payment-intent id (the generic path; use this for a new provider). If your manifest declares
globalWebhookRouting.paymentIntentIdPaths, the router reads the first of those dotted paths that holds a value and matches it againstCheckout.paymentIntentId, the id yourcreatePaymentIntentreturned, on a checkout bound to an installation of your app. A provider that asks for one fixed "default notify URL" needs only this declaration, with no new code in the router. For example, a provider whose notification carries the intent id atprocessIddeclares["data.data.processId", "data.processId", "processId"], and its default server-response URL is thenPOST /api/apps/webhooks-global/<appSlug>/payment:"globalWebhookRouting": { "paymentIntentIdPaths": ["data.data.processId", "data.processId", "processId"] }The router reads the latest published version of your manifest for this. The rules are enforced at read time, not by manifest validation (nothing rejects a manifest that breaks them, the router just ignores the excess):
- Only the first 10 entries of
paymentIntentIdPathsare tried; entries 11 and later are never read, and non-string entries are skipped. - A path is a dot-separated chain of own property names (
data.data.processId). There is no array indexing and no wildcard. - The value found must be a non-empty string or a number, and its string form must be at most 200 characters; otherwise this step resolves nothing and the router moves on to the payload shapes below.
- The id is matched only against checkouts whose payment installation belongs to the app in the URL (
:appSlug), and the installation must beINSTALLED.
- Only the first 10 entries of
WhatsApp Business Account payloads come next, only on the whatsapp app: when the body has object: "whatsapp_business_account", the owning installation is the one whose config.wabaId equals entry[0].id, and a body with no entry[0].id resolves nothing.
Then, for PayPal-shaped bodies and Stripe-shaped bodies, the steps below (a body with event_type and resource takes the PayPal path; otherwise the router reads data.object, and a body with neither resolves nothing):
checkoutId:client_reference_idon Checkout Session events, ormetadata.brainerceCheckoutIdon PaymentIntent events (stripe-payments sets it on both), matched againstCheckout.storeId, then the app's installation on that store.- Donations have no checkout. A
checkout.session.*event whosedata.object.idis the Checkout Session id stored on a donation, resolves to that donation's installation (or any installed one on its store). Acheckout.session.completedevent withmode: "setup"(a donor replacing the card behind a monthly donation) resolves throughmetadata.brainerceSubscriptionIdto the recurring subscription's store.
- Donations have no checkout. A
- PaymentIntent id:
data.object.payment_intenton Charge events, ordata.object.idon PaymentIntent events, matched first against the intent id stored on a donation, then against an existingPaymentrow written by an earlier webhook on the same transaction (Payment.externalId), via itspaymentInstallationId/storeId. - Connected-account id (
account.updatedevents): matched againstAppInstallation.config.stripeTestAccountId/.stripeLiveAccountIdfor the givenappSlug.
For PayPal-shaped events ({ event_type, resource }), the equivalent chain is resource.custom_id / purchase_units[0].custom_id (the checkoutId), purchase_units[0].reference_id (the storeId), and the PayPal order id (supplementary_data.related_ids.order_id, or resource.id on CHECKOUT.ORDER.* events) matched against Checkout.paymentIntentId, then against the intent id of a donation.
If none of these resolve (an event type your app doesn't emit, or a payload shape the router doesn't recognize), the route returns 404 rather than silently guessing an installation. It never falls back to processing an unresolvable event.
Rate limit: POST on this route is limited to 30,000 requests per minute, counted per :appSlug and shared by every installation of that app (not per source IP), because one request can cost several database lookups before any signature is checked. Over the limit, the route answers 429.
Meta subscription handshake (GET)
GET /api/apps/webhooks-global/:appSlug/:path(*)?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...Meta will not save a callback URL in its App Dashboard until a GET to it echoes back hub.challenge. Every other provider on these routes signs its own POSTs and needs no handshake, so this is the only GET. It returns the hub.challenge value as plain text when all of these hold, and 400 Invalid verification token otherwise:
hub.modeissubscribe.hub.challengeis present.hub.verify_tokenequals the platform-wideMETA_WEBHOOK_VERIFY_TOKEN(one value for every app, compared in constant time; if the variable is unset every handshake is refused).
The handshake resolves no installation, reads no body and touches no database, so a successful GET proves nothing about an app being installed. Real trust stays on the POST path, where the connector verifies Meta's X-Hub-Signature-256 against originalRawBody. Limit: 60 requests per minute per client IP (this route has no appSlug tracker).
Trust model: identical to Route 1. This route is @Public() and unsigned; the core does not verify anything about the payload before resolving an installation and forwarding it. Your app is still responsible for verifying the provider's signature in originalHeaders before treating the event as genuine: resolving which installation an event belongs to is a routing concern, not an authentication one. Everything in What NOT to do → Don't trust body.originalPayload without verifying applies here exactly as it does to Route 1.
Once resolved, the event is handed to the same routeWebhook path used by Route 1, so forwarding, caching, and side-effects (payment event enqueueing, config/secrets updates, invoice writes) all behave identically to a per-installation webhook.
Side-effects your response can trigger
When your handler returns from a webhook, the core inspects the response shape and applies zero or more side-effects. This is how your app writes back to the platform without needing its own authenticated API calls.
Normalized payment event → queue
If your app is category PAYMENT_GATEWAY and your response has response.event.type, the core enqueues a job on the payment-webhook queue:
return {
received: true,
processed: true,
event: {
type: 'payment.succeeded',
checkoutId: flatPayload.cField1,
paymentIntentId: flatPayload.processId,
payment: {
externalId: `${transactionId}:${transactionToken}`,
amount: parseFloat(sum),
currency: 'ILS',
paymentMethod: 'credit_card',
},
},
};The BullMQ worker creates the order, applies coupons, triggers confirmation emails, etc.
Invoice payload → Payment.providerData write
For providers that push invoices asynchronously (through the invoiceNotifyUrl the core hands your app), return a normalized invoice object under data.invoice:
return {
received: true,
processed: true,
data: {
invoice: {
processId: flatPayload.processId,
invoiceNumber: flatPayload.invoiceNumber,
invoiceUrl: flatPayload.invoiceUrl,
},
},
};The core calls PaymentService.applyInvoiceForInstallation(), which enforces that the target Payment belongs to the calling installation and refuses to overwrite an existing invoice. A cross-installation attempt throws ForbiddenException; a replay-after-legitimate throws ConflictException.
Normalized shipping event → queue
If your app is category SHIPPING and your response has response.shippingEvent.type, the core enqueues it on the shipping carrier queue, keyed by the installation's store. An event without trackingNumber is logged and dropped. A queue failure never fails the webhook response.
WhatsApp topics → queue
For the whatsapp app only: a response with a non-empty data.topics array ({ wabaId, field, value } items, already verified and normalized by the connector) is enqueued on the WhatsApp webhook queue. The core persists nothing inline.
Verification claim on payment success events
A payment.succeeded response should carry verification.method (for example the provider's signature or an API re-fetch). When the app's manifest declares integration.paymentContract.contractVersion and the method is missing or none, the core logs a warning; with the server setting PAYMENT_VERIFICATION_ENFORCEMENT=quarantine it instead drops the event, creates no order, writes an audit row (payment.quarantined_unverified) and leaves the checkout PENDING for the reconciler or manual review. The default is log only.
Config update → AppInstallation.config
If your webhook discovers persistent state worth remembering on the installation (e.g., a registration webhook that returns the merchant's account ID at the provider), return it under data.configUpdate:
return {
received: true,
processed: true,
data: {
configUpdate: {
providerMerchantId: '12345',
oauthConnectedAt: new Date().toISOString(),
},
},
};The core merges this into AppInstallation.config (plain JSON, not encrypted).
Secrets update → AppInstallation.encryptedSecrets
For persistent credentials received via webhook (new API tokens, rotated keys), use data.secretsUpdate instead:
return {
received: true,
processed: true,
data: {
secretsUpdate: {
providerUserId: 'user_xyz',
},
},
};The core encrypts this with EncryptionService and persists to AppInstallation.encryptedSecrets.
What NOT to do
Don't hold state between requests
Your app is a stateless HTTP service. No in-memory Maps keyed by installationId. No SQLite. No Redis (except via the queue, which is the core's job). If you need state, put it in config or encryptedSecrets and return it as configUpdate/secretsUpdate from a webhook handler. The core persists it and passes it back on the next call.
Don't trust body.originalPayload without verifying
The core forwards provider payloads unverified on Route 1 (unsigned). Anyone who knows an installationId can POST a fake body. Your handler MUST either:
- Verify the provider's signature in
originalHeaders(Stripe, PayPal, Shopify, Clerk) - Call the provider's API with an ID from the payload to fetch the canonical record (a "get payment result" or "get payment process info" endpoint, if the provider has one)
- Use Route 2 instead (HMAC URL) and trust the core's signature verification
If you do none of these, your app is a cross-tenant stored-content injection vector.
Don't build your own signed URLs
The backend mints the URL. Your app just reads body.metadata.invoiceNotifyUrl (or similar) and passes it to the provider. Do not:
- Concatenate your own
backendUrl + /api/apps/webhooks-signed/... - Hash anything yourself
- Store HMAC keys in your app
The HMAC key never leaves the backend. Only the signed URL travels through your app, and your app treats it as an opaque string.
Don't bypass the core for outbound provider calls
All provider API calls should be made from your app's /payments/create-intent, /payments/refund, etc. handlers using the config and secrets passed in by the core. Never cache provider credentials in your app's process memory across requests.
Choosing between Route 1, Route 2, and Route 3
Does the event arrive on a platform-level account shared across every
installation, rather than a per-merchant one (Stripe Connect TEST-mode,
PayPal app-level webhooks)?
│
├── Yes
│ └── Use Route 3 (global). A per-installation URL doesn't work here —
│ there is no per-installation account to address. Register the
│ fixed /webhooks-global/:appSlug/:path URL once with the provider;
│ the core resolves the installation from the event content. Your
│ app still verifies the provider's signature, exactly as Route 1.
│
└── No — the event already carries (or can carry) a per-installation URL
│
└── Does your provider natively sign its webhooks with a secret you hold?
│
├── Yes (Stripe, PayPal, Shopify, Clerk, Svix, GitHub, etc.)
│ └── Use Route 1 (unsigned). Verify the provider's signature in
│ your handler using `body.originalHeaders` and `body.secrets`.
│
└── No (fixed notify-URL callbacks, legacy gateways, custom APIs)
│
└── Can you verify the webhook by calling back to the provider's
API with an ID from the payload?
│
├── Yes (a payment-result lookup endpoint)
│ └── Use Route 2 (signed URL). The HMAC URL is your primary
│ auth; optionally add provider-API re-fetch as
│ defense-in-depth, treating the webhook body as a
│ trigger rather than a data source (the platform's
│ own payment apps do this for payment webhooks).
│
└── No (truly unauthenticated provider)
└── Use Route 2. The HMAC URL is the only thing between
you and a public POST endpoint. Never use Route 1 in
this case.Operational notes
- Reverse-proxy log sanitization is mandatory in production. The HMAC segment in signed URLs will appear in nginx/Cloudflare/ALB access logs unless you configure them to redact path segments under
/api/apps/webhooks-signed/*. The app-levelRedactingLoggerhandles NestJS logs, but not reverse-proxy logs. This is the single most important ops config for Route 2 security. - Key rotation is automatic. The per-installation HMAC key rotates every 90 days (
SIGNED_WEBHOOK_HMAC_KEY_ROTATION_AFTER_SECONDS). For suspected-compromise, callPaymentService.rotateWebhookHmacKeyImmediately(installationId), and expect brief invoice-notify downtime (≤ 1 hour) while in-flight URLs expire. - Ed25519 signing between core and app is mandatory in production. Set
BRAINERCE_SIGNING_PRIVATE_KEYon the core andBRAINERCE_PUBLIC_KEYon each app. Apps verify every incoming core request'sX-Brainerce-Signatureheader. In development the key pair is auto-generated and logged at startup; in production, missingBRAINERCE_SIGNING_PRIVATE_KEYis a boot-time fatal error. - Test your webhook handlers with provider sandbox URLs first. The payment provider apps (for example paypal) support sandbox mode via provider-specific env vars or config flags.