Marketplace Webhooks

Listen for events from Brainerce in your app, such as order created, inventory changed, customer registered, and more.

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 / Grow / 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:

  1. 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.
  2. Your app stays stateless. You don't need a database, a KV store, or any persistent secrets. Every call from core arrives with fresh config and secrets in the request body, decrypted from AppInstallation.encryptedSecrets.
  3. 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. 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 and the original headers under originalHeaders:

// apps/grow-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: Grow/Meshulam, Cardcom's direct callbacks, 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 keying
  • expires: Unix seconds timestamp; the URL becomes useless after this
  • hmac: HMAC-SHA256(perInstallationKey, "${installationId}|${expires}|${path}") hex-encoded
  • path: 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/grow-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('[grow] 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 four additional layers reduce the blast radius of any residual leak:

  1. Short expiry (1 hour). Set in SIGNED_WEBHOOK_MAX_AGE_SECONDS. Captured URLs become useless quickly.
  2. Per-installation rate limiting (30,000 req/min, i.e. 500/sec). InstallationThrottlerGuard keys the bucket by :installationId from 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.
  3. Idempotency lock on invoice writes. PaymentService.applyInvoiceForInstallation refuses to overwrite an existing invoiceUrl. First write wins, so a captured URL replayed after the legitimate webhook succeeds fails with 409 Conflict. Same-data replays are silent no-ops.
  4. 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.
  5. Log sanitization. RedactingLogger strips the HMAC segment from any log line containing /webhooks-signed/, so the signature never appears in backend logs even if someone passes req.url to logger.log. Configure your reverse proxy (nginx, Cloudflare, ALB) to similarly redact /api/apps/webhooks-signed/* path segments.
  6. Automatic HMAC key rotation (90 days). ensureWebhookHmacKey rotates the per-installation key after SIGNED_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, call PaymentService.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/payment

resolveInstallationForEvent (in webhook-router.controller.ts) tries, in order:

  1. checkoutId: client_reference_id on Checkout Session events, or metadata.brainerceCheckoutId on PaymentIntent events (stripe-payments sets it on both), matched against Checkout.storeId.
  2. PaymentIntent id: data.object.payment_intent on Charge events, or data.object.id on PaymentIntent events, matched against an existing Payment row written by an earlier webhook on the same transaction, via its paymentInstallationId / storeId.
  3. Connected-account id (account.updated events): matched against AppInstallation.config.stripeTestAccountId / .stripeLiveAccountId for the given appSlug.

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 matched against Checkout.paymentIntentId.

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.

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 (Grow's invoiceNotifyUrl), 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.

Config update → AppInstallation.config

If your webhook discovers persistent state worth remembering on the installation (e.g., Grow's registration webhook returns the merchant's Grow user ID), return it under data.configUpdate:

return {
  received: true,
  processed: true,
  data: {
    configUpdate: {
      growMerchantId: '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: {
      growUserId: '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 (Cardcom's getLpResult, Grow's getPaymentProcessInfo)
  • 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 (Grow, Cardcom direct 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 (Cardcom getLpResult, Grow getPaymentProcessInfo)
                │   └── 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 (cardcom-payments
                │       does 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-level RedactingLogger handles 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, call PaymentService.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_KEY on the core and BRAINERCE_PUBLIC_KEY on each app. Apps verify every incoming core request's X-Brainerce-Signature header. In development the key pair is auto-generated and logged at startup; in production, missing BRAINERCE_SIGNING_PRIVATE_KEY is a boot-time fatal error.
  • Test your webhook handlers with provider sandbox URLs first. All four provider apps (grow, cardcom, paypal, woocommerce) support sandbox mode via provider-specific env vars or config flags.