Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/webhooks-receiver.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ receiver.onAll(
| Database | `Database.RecordInserted`, `RecordUpdated`, `RecordDeleted`, `RecordReplaced`, `RecordResponsibilityChanged`, `RecordsInserted`, `RecordsUpdated`, `RecordsDeleted` |
| Membership | `Membership.UserRegistered`, `UserInvited`, `UserVerified`, `UserUpdated`, `UserDeleted`, `UserBlocked`, `UserReactivated` |
| Files | `Files.FileUploaded`, `FileDeleted` |
| Payments | `Payments.WebhookReceived` |

Full list: `NORBIX_WEBHOOK_EVENT_NAMES` · grouped: `NORBIX_WEBHOOK_EVENT_GROUPS`.

Expand Down Expand Up @@ -229,10 +230,32 @@ events give the **entity directly**. Arbitrary mutations give `{ from, to }`.
| `database.record.updated` / `replaced` | `{ from: T; to: T }` |
| `database.records.inserted` | `T[]` |
| `files.file.uploaded` | `FileResourceRefDto` |
| `payments.webhook.received` | `PaymentFactDto` |

Wrapper ids (user id, schema, record ids) live on `event.metadata`. For manual
typing outside `receiver.on`, use `NorbixWebhookPayload<'event.name'>`.

### Payments

Every payment provider callback (Stripe, Apple In-App, Google In-App, Paddle, …)
reaches your endpoint as ONE event, `payments.webhook.received`. The payload is
the normalized payment fact — the provider's raw body never leaves Norbix. Branch
on the provider's own event name, which is `payload.eventName`:

```ts
receiver.on(NorbixWebhookEvents.Payments.WebhookReceived, async (payment, event) => {
// payment: PaymentFactDto — provider, eventName, status, amountMinor, currency, externalIds, …
if (payment.eventName === 'payment_intent.succeeded' && payment.status === 'Succeeded') {
await markOrderPaid(payment.externalIds?.paymentIntent, payment.amountMinor, payment.currency);
}
// event.metadata.payment = { provider, providerEventId, eventName } — de-duplicate on providerEventId
});
```

`amountMinor` is in minor units (cents); it is absent for events with no amount
(subscription changes, Google Play notifications). Subscribe a destination to
`payments.webhook.received` and point a Payments trigger with a Webhook action at it.

## Configuration

`new NorbixWebhookReceiver(options?)` reads these env vars; any option you pass
Expand Down
7 changes: 6 additions & 1 deletion src/webhooks/event-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export interface NorbixWebhookPayloadMap<TDocument = Record<string, unknown>> {
// files — entity
'files.file.uploaded': CodeMashHub2.FileResourceRefDto;
'files.file.deleted': NorbixWebhookFileDeleted;
// payments — the normalized payment fact (never the provider's raw payload).
// The provider's own event name is `eventName`; amounts are minor units.
'payments.webhook.received': CodeMashHub2.PaymentFactDto;
}

/** Resolve the typed payload for a known event (with optional document type). */
Expand All @@ -63,6 +66,8 @@ export interface NorbixWebhookEventMetadata {
record?: { id: string };
/** Record ids for batch database events. */
records?: { ids: string[] };
/** Integration id for files.* events (and database events that carry it). */
/** Integration id for files.* and payments.* events (and database events that carry it). */
integrationId?: string;
/** payments.* events: the provider and its own event id / name (e.g. Stripe `evt_…`, `payment_intent.succeeded`). */
payment?: { provider: string; providerEventId: string; eventName: string };
}
15 changes: 15 additions & 0 deletions src/webhooks/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const NORBIX_WEBHOOK_EVENT_NAMES = [
'membership.user.reactivated',
'files.file.uploaded',
'files.file.deleted',
'payments.webhook.received',
] as const;

export type NorbixWebhookEventName = (typeof NORBIX_WEBHOOK_EVENT_NAMES)[number];
Expand Down Expand Up @@ -55,6 +56,15 @@ export const NorbixWebhookEvents = {
FileUploaded: 'files.file.uploaded',
FileDeleted: 'files.file.deleted',
},
Payments: {
/**
* A payment provider (Stripe, Apple In-App, Google In-App, …) called the
* project's payments webhook. The payload is the normalized payment fact;
* the provider's own event name (`payment_intent.succeeded`, …) is
* `payload.eventName`.
*/
WebhookReceived: 'payments.webhook.received',
},
} as const satisfies Record<string, Record<string, NorbixWebhookEventName>>;

export interface NorbixWebhookEventGroup {
Expand Down Expand Up @@ -96,4 +106,9 @@ export const NORBIX_WEBHOOK_EVENT_GROUPS: NorbixWebhookEventGroup[] = [
label: 'Files',
events: ['files.file.uploaded', 'files.file.deleted'],
},
{
group: 'payments',
label: 'Payments',
events: ['payments.webhook.received'],
},
];
20 changes: 20 additions & 0 deletions src/webhooks/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
* - Entity events → `payload` is the entity (user / document / file).
* - Mutation events → `payload` is `{ from, to }`.
* - Batch events → `payload` is the array.
* - Payments events → `payload` is the normalized payment fact.
*
* Wrapper ids (record id, schema, user id, …) are moved onto `metadata`.
* Unknown events fall back to `payload = envelope.data`, `metadata = {}`.
Expand Down Expand Up @@ -94,5 +95,24 @@ export function normalizeNorbixWebhook(envelope: NorbixWebhookEnvelope): NorbixW
}
}

// Payments: the whole data object IS the normalized fact; lift the
// provider ids onto metadata so a handler can de-duplicate / look them up.
if (event.startsWith('payments.')) {
const metadata: NorbixWebhookEventMetadata = {};
if (typeof d.integrationId === 'string') metadata.integrationId = d.integrationId;
if (
typeof d.provider === 'string' &&
typeof d.providerEventId === 'string' &&
typeof d.eventName === 'string'
) {
metadata.payment = {
provider: d.provider,
providerEventId: d.providerEventId,
eventName: d.eventName,
};
}
return { payload: data, metadata };
}

return { payload: data, metadata: {} };
}
6 changes: 6 additions & 0 deletions tests/webhooks/event-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ describe('webhooks payload types', () => {
>().toEqualTypeOf<NorbixWebhookMutation<CodeMashHub2.UserDto>>();
});

it('payments.webhook.received payload is the normalized payment fact', () => {
expectTypeOf<
NorbixWebhookPayload<'payments.webhook.received'>
>().toEqualTypeOf<CodeMashHub2.PaymentFactDto>();
});

it('database.records.inserted payload is the document array', () => {
expectTypeOf<
NorbixWebhookPayload<'database.records.inserted', CodeMashHub2.UserDto>
Expand Down
47 changes: 47 additions & 0 deletions tests/webhooks/receiver.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';

import {
NorbixWebhookEvents,
NorbixWebhookReceiver,
NORBIX_WEBHOOK_HEADERS,
NorbixWebhookSignatureError,
Expand Down Expand Up @@ -170,6 +171,52 @@ describe('NorbixWebhookReceiver', () => {
expect(handler.mock.calls[0]?.[0]).toEqual({ from, to });
});

it('hands a payment delivery to its handler as the fact, provider ids on metadata', async () => {
// The shape a payments Webhook trigger delivers (gateway L4
// PaymentsTriggerFiresWebhookE2ETests): event = the one Norbix name,
// data = the normalized fact, never the provider's raw payload.
const fact = {
integrationId: 'int_stripe',
provider: 'Stripe',
providerEventId: 'evt_1',
eventName: 'payment_intent.succeeded',
status: 'Succeeded',
amountMinor: 1099,
currency: 'USD',
externalIds: { customer: 'cus_1', paymentIntent: 'pi_1' },
occurredOn: '2026-10-12T07:59:00Z',
receivedOn: '2026-10-12T07:59:30Z',
};
const receiver = new NorbixWebhookReceiver();
const handler = vi.fn();
receiver.on(NorbixWebhookEvents.Payments.WebhookReceived, handler);

const result = await receiver.handle({
rawBody: JSON.stringify({
id: 'dlv_p',
eventId: 'evt_change_1',
event: 'payments.webhook.received',
createdOn: '2026-10-12T08:00:00Z',
accountId: 'acc_1',
projectId: 'pr_1',
triggerId: 'trg_p',
data: fact,
}),
headers: {},
});

expect(result.handled).toBe(true);
expect(handler.mock.calls[0]?.[0]).toEqual(fact);
const event = handler.mock.calls[0]?.[1];
expect(event.name).toBe('payments.webhook.received');
expect(event.metadata.integrationId).toBe('int_stripe');
expect(event.metadata.payment).toEqual({
provider: 'Stripe',
providerEventId: 'evt_1',
eventName: 'payment_intent.succeeded',
});
});

it('lifts schema + record id onto event.metadata for db events', async () => {
const doc = { id: 'rec_1', email: 'a@b.io' };
const receiver = new NorbixWebhookReceiver();
Expand Down
Loading