Labs / System
Northline Pay
A fictional payment service provider for marketplaces. Every lab is set inside it.
What Northline Pay is
Northline Pay is a fictional payment service provider (PSP) that processes card, SEPA, and Interac payments for online marketplaces in Canada and the European Union. A marketplace integrates the Merchant API once, then creates payments on behalf of buyers, splits each payment across seller accounts, refunds, and receives webhooks. Nothing here corresponds to a real company. The system is realistic because the labs only work if the evidence behaves like production evidence.
Every lab on this site is set inside Northline Pay. Once you know the system from one mission, the next mission does not need to re-explain it. That is deliberate: real analysts investigate systems they already know.
How the platform is built
The platform has six services. An analyst on the delivery team gets read access to the API contract, the event catalogue, the database, the logs, and the monitoring dashboards.
| Service | Responsibility | Talks to |
|---|---|---|
| Merchant API | The public REST API merchants call. Validates requests, enforces idempotency, and returns payment and refund resources. | Orchestrator (synchronous), PostgreSQL |
| Orchestrator | Runs the payment lifecycle: authorization, 3-D Secure, capture, refund. Publishes every state change as an event. | Acquirers (external), Kafka |
| Ledger | Consumes captured and refunded events and posts double-entry ledger entries. Emits ledger.entry.posted. | Kafka, PostgreSQL |
| Settlement | Consumes ledger entries and groups them into T+2 settlements per merchant. Emits settlement.payment.settled. | Kafka, PostgreSQL |
| Webhook dispatcher | Consumes public events and delivers signed webhooks to merchant endpoints with retries. | Kafka, merchant endpoints |
| Risk | Scores payments before authorization. Can decline. | Orchestrator (synchronous) |
The happy path of a captured card payment crosses all of them:
- The merchant calls
POST /v1/payments. The Merchant API validates the request and calls the Orchestrator. - The Orchestrator asks Risk for a score, then authorizes with the acquirer. It publishes
payments.payment.authorized. - On capture (automatic or
POST /v1/payments/{id}/capture), the Orchestrator publishespayments.payment.captured. - The Ledger consumes the captured event, posts the entries, and publishes
ledger.entry.posted. - Settlement consumes the ledger event and, at T+2, publishes
settlement.payment.settled. - The Webhook dispatcher consumes each public event and delivers
payment.authorized,payment.captured, andpayment.settledto the merchant.
Every event and every table row carries the payment_id. It is the identifier you trace on. Requests to the Merchant API also carry a request_id, returned in the Northline-Request-Id response header and stored with the request log.
Conventions you can rely on
- Amounts are integers in minor units (cents).
12550is 125.50 CAD. - Currencies are ISO 4217 codes in upper case.
- Timestamps in events and in the database are RFC 3339 in UTC, with the
Zsuffix. - Identifiers are prefixed:
pay_for payments,re_for refunds,evt_for events,led_for ledger entries,set_for settlements,req_for API requests,mer_for merchants. - Environments:
sandboxandproduction. The labs use sandbox data unless the mission says otherwise.
The artifacts
The three files below are the platform as its teams published it. They are the source of truth for every mission. When a mission adds evidence (requirements, sample responses, logs, database extracts), that evidence is specific to the mission and sits with it.
Merchant API contract (OpenAPI 3.1)
Payments, captures, refunds, listing, and webhooks, as the API team published it to merchants.
Platform artifact openapi.yaml 470 lines download show
openapi: 3.1.0
info:
title: Northline Pay Merchant API
version: "1.4.0"
description: |
Create payments on behalf of buyers, capture, refund, and list them.
All amounts are expressed in the payment currency.
Authenticate with your secret API key as a bearer token.
contact:
name: Northline Pay API team
email: api@northlinepay.example
servers:
- url: https://api.sandbox.northlinepay.example/v1
description: Sandbox
- url: https://api.northlinepay.example/v1
description: Production
security:
- apiKey: []
paths:
/payments:
post:
operationId: createPayment
summary: Create a payment
description: |
Creates a payment for a buyer. With `capture_method: automatic` the
payment is authorized and captured in one call. With `manual`, call
the capture endpoint later.
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreatePaymentRequest"
responses:
"201":
description: Payment created
headers:
Northline-Request-Id:
$ref: "#/components/headers/RequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"402":
description: The payment method was declined
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
get:
operationId: listPayments
summary: List payments
description: Returns the merchant's payments, most recent first.
parameters:
- name: limit
in: query
description: Number of payments to return.
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: status
in: query
schema:
$ref: "#/components/schemas/PaymentStatus"
responses:
"200":
description: A page of payments
content:
application/json:
schema:
type: object
required: [object, data, has_more]
properties:
object:
type: string
const: list
data:
type: array
items:
$ref: "#/components/schemas/Payment"
has_more:
type: boolean
"401":
$ref: "#/components/responses/Unauthorized"
/payments/{payment_id}:
get:
operationId: getPayment
summary: Retrieve a payment
parameters:
- $ref: "#/components/parameters/PaymentId"
responses:
"200":
description: The payment
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"
"404":
$ref: "#/components/responses/NotFound"
/payments/{payment_id}/capture:
post:
operationId: capturePayment
summary: Capture an authorized payment
parameters:
- $ref: "#/components/parameters/PaymentId"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
content:
application/json:
schema:
type: object
properties:
amount:
type: number
description: Amount to capture. Defaults to the authorized amount.
responses:
"200":
description: The captured payment
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: The payment is not in a capturable state
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/refunds:
post:
operationId: createRefund
summary: Refund a captured payment
description: Refunds all or part of a captured payment.
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [payment_id]
properties:
payment_id:
type: string
amount:
type: number
description: Amount to refund. Defaults to the full captured amount.
reason:
type: string
enum: [requested_by_customer, duplicate, fraudulent]
responses:
"201":
description: Refund created
content:
application/json:
schema:
$ref: "#/components/schemas/Refund"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
/refunds/{refund_id}:
get:
operationId: getRefund
summary: Retrieve a refund
parameters:
- name: refund_id
in: path
required: true
schema:
type: string
responses:
"200":
description: The refund
content:
application/json:
schema:
$ref: "#/components/schemas/Refund"
"404":
$ref: "#/components/responses/NotFound"
webhooks:
payment.authorized:
post:
summary: A payment was authorized
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookEvent"
responses:
"200":
description: Acknowledge with any 2xx status
payment.captured:
post:
summary: A payment was captured
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookEvent"
responses:
"200":
description: Acknowledge with any 2xx status
payment.failed:
post:
summary: A payment failed or was declined
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookEvent"
responses:
"200":
description: Acknowledge with any 2xx status
refund.succeeded:
post:
summary: A refund was completed
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookEvent"
responses:
"200":
description: Acknowledge with any 2xx status
components:
securitySchemes:
apiKey:
type: http
scheme: bearer
description: Your secret key, `sk_test_...` in sandbox and `sk_live_...` in production.
parameters:
PaymentId:
name: payment_id
in: path
required: true
schema:
type: string
pattern: "^pay_[A-Za-z0-9]{16}$"
IdempotencyKey:
name: Idempotency-Key
in: header
required: false
description: A unique key you generate to safely retry the request.
schema:
type: string
maxLength: 64
headers:
RequestId:
description: Identifier of this request, for support tickets.
schema:
type: string
responses:
BadRequest:
description: The request was invalid
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
Unauthorized:
description: The API key is missing or invalid
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: No such resource for this merchant
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
schemas:
PaymentStatus:
type: string
enum: [pending, authorized, captured, failed, canceled]
CreatePaymentRequest:
type: object
required: [amount, currency, payment_method]
properties:
amount:
type: number
description: The payment amount.
currency:
type: string
description: Three-letter currency code.
capture_method:
type: string
enum: [automatic, manual]
default: automatic
payment_method:
$ref: "#/components/schemas/PaymentMethodInput"
customer:
type: object
properties:
email:
type: string
format: email
description:
type: string
maxLength: 200
split:
type: array
description: How the amount is distributed across seller accounts.
items:
type: object
required: [account_id, amount]
properties:
account_id:
type: string
amount:
type: number
metadata:
type: object
additionalProperties:
type: string
PaymentMethodInput:
type: object
required: [type, token]
properties:
type:
type: string
enum: [card, sepa_debit, interac]
token:
type: string
description: A payment method token created with Northline.js.
Payment:
type: object
required: [id, object, status, amount, currency, capture_method, payment_method, created_at]
properties:
id:
type: string
object:
type: string
const: payment
status:
$ref: "#/components/schemas/PaymentStatus"
amount:
type: number
currency:
type: string
capture_method:
type: string
enum: [automatic, manual]
payment_method:
type: object
properties:
type:
type: string
card:
type: object
properties:
brand:
type: string
last4:
type: string
exp_month:
type: integer
exp_year:
type: integer
customer:
type: object
properties:
email:
type: string
split:
type: array
items:
type: object
properties:
account_id:
type: string
amount:
type: number
failure_code:
type: string
failure_message:
type: string
metadata:
type: object
additionalProperties:
type: string
created_at:
type: string
format: date-time
Refund:
type: object
required: [id, object, payment_id, amount, currency, status, created_at]
properties:
id:
type: string
object:
type: string
const: refund
payment_id:
type: string
amount:
type: number
currency:
type: string
status:
type: string
enum: [pending, succeeded, failed]
reason:
type: string
created_at:
type: string
format: date-time
WebhookEvent:
type: object
required: [id, type, created_at, data]
properties:
id:
type: string
type:
type: string
created_at:
type: string
format: date-time
data:
type: object
description: The payment or refund resource, as returned by the API.
Error:
type: object
required: [type, code, message]
properties:
type:
type: string
enum: [invalid_request_error, authentication_error, card_error, api_error]
code:
type: string
description: |
One of: invalid_request, authentication_failed, payment_not_found,
payment_not_capturable, card_declined, rate_limited.
message:
type: string
param:
type: string
description: The request field the error refers to, when applicable. Event catalogue (Kafka topics and schemas)
Every topic on the backbone, its key, its producer, its consumers, and the JSON schema of the event.
Platform artifact topics.yaml 146 lines download show
# Northline Pay event catalogue
# Owner: platform team. Every topic is keyed by payment_id so all events of
# one payment land on the same partition, in order.
#
# Conventions
# - event_id is unique per event, prefixed evt_.
# - occurred_at is RFC 3339 in UTC with the Z suffix. Consumers validate it.
# - Schemas are JSON Schema. A consumer that fails validation sends the
# message to its dead-letter topic and does not retry.
topics:
- name: payments.payment.authorized
version: 1
key: payment_id
producer: orchestrator
consumers: [webhook-dispatcher, risk]
public: true # delivered to merchants as payment.authorized
schema:
type: object
required: [event_id, event_type, occurred_at, payment_id, merchant_id, amount, currency]
properties:
event_id: { type: string, pattern: "^evt_[A-Za-z0-9]{16}$" }
event_type: { type: string, const: payment.authorized }
occurred_at: { type: string, format: date-time }
payment_id: { type: string, pattern: "^pay_[A-Za-z0-9]{16}$" }
merchant_id: { type: string, pattern: "^mer_[A-Za-z0-9]{8}$" }
amount: { type: integer, minimum: 1 }
currency: { type: string, enum: [CAD, EUR] }
- name: payments.payment.captured
version: 1
key: payment_id
producer: orchestrator
consumers: [ledger, webhook-dispatcher]
public: true # delivered as payment.captured
schema:
type: object
required: [event_id, event_type, occurred_at, payment_id, merchant_id, captured_amount, currency, captured_at]
properties:
event_id: { type: string }
event_type: { type: string, const: payment.captured }
occurred_at: { type: string, format: date-time }
payment_id: { type: string }
merchant_id: { type: string }
captured_amount: { type: integer, minimum: 1 }
currency: { type: string, enum: [CAD, EUR] }
captured_at: { type: string, format: date-time }
- name: payments.payment.failed
version: 1
key: payment_id
producer: orchestrator
consumers: [webhook-dispatcher]
public: true
schema:
type: object
required: [event_id, event_type, occurred_at, payment_id, merchant_id, failure_code]
properties:
event_id: { type: string }
event_type: { type: string, const: payment.failed }
occurred_at: { type: string, format: date-time }
payment_id: { type: string }
merchant_id: { type: string }
failure_code: { type: string }
failure_message: { type: string }
- name: refunds.refund.succeeded
version: 1
key: payment_id
producer: orchestrator
consumers: [ledger, webhook-dispatcher]
public: true
schema:
type: object
required: [event_id, event_type, occurred_at, payment_id, refund_id, merchant_id, amount, currency]
properties:
event_id: { type: string }
event_type: { type: string, const: refund.succeeded }
occurred_at: { type: string, format: date-time }
payment_id: { type: string }
refund_id: { type: string, pattern: "^re_[A-Za-z0-9]{16}$" }
merchant_id: { type: string }
amount: { type: integer, minimum: 1 }
currency: { type: string, enum: [CAD, EUR] }
- name: ledger.entry.posted
version: 1
key: payment_id
producer: ledger
consumers: [settlement]
public: false
schema:
type: object
required: [event_id, event_type, occurred_at, payment_id, entry_id, merchant_id, kind, amount, currency]
properties:
event_id: { type: string }
event_type: { type: string, const: ledger.entry.posted }
occurred_at: { type: string, format: date-time }
payment_id: { type: string }
entry_id: { type: string, pattern: "^led_[A-Za-z0-9]{16}$" }
merchant_id: { type: string }
kind: { type: string, enum: [capture, refund] }
amount: { type: integer }
currency: { type: string, enum: [CAD, EUR] }
- name: settlement.payment.settled
version: 1
key: payment_id
producer: settlement
consumers: [webhook-dispatcher]
public: true # delivered as payment.settled
schema:
type: object
required: [event_id, event_type, occurred_at, payment_id, settlement_id, merchant_id, amount, currency]
properties:
event_id: { type: string }
event_type: { type: string, const: payment.settled }
occurred_at: { type: string, format: date-time }
payment_id: { type: string }
settlement_id: { type: string, pattern: "^set_[A-Za-z0-9]{12}$" }
merchant_id: { type: string }
amount: { type: integer }
currency: { type: string, enum: [CAD, EUR] }
dead_letter_topics:
- name: ledger.dlq
owner: ledger
description: Messages the ledger consumer could not validate or process. Each message keeps the original payload and adds error, source_topic, source_offset, and failed_at headers.
- name: settlement.dlq
owner: settlement
- name: webhook-dispatcher.dlq
owner: webhook-dispatcher
consumer_groups:
ledger:
topics: [payments.payment.captured, refunds.refund.succeeded]
dead_letter: ledger.dlq
on_validation_error: dead_letter # no retry, message is never reprocessed automatically
settlement:
topics: [ledger.entry.posted]
dead_letter: settlement.dlq
webhook-dispatcher:
topics: [payments.payment.authorized, payments.payment.captured, payments.payment.failed, refunds.refund.succeeded, settlement.payment.settled]
dead_letter: webhook-dispatcher.dlq
delivery_retries: 8 # exponential backoff over 24 hours Database schema (PostgreSQL)
The operational tables an analyst gets read access to during an investigation.
Platform artifact schema.sql 105 lines download show
-- Northline Pay operational schema (PostgreSQL 16), read replica.
-- Analysts on the delivery team get SELECT on these tables during an
-- investigation. Timestamps are timestamptz stored in UTC.
create table merchants (
id text primary key, -- mer_XXXXXXXX
name text not null,
country char(2) not null,
webhook_url text,
webhook_secret text, -- HMAC secret, never shown in full
created_at timestamptz not null default now()
);
create table payments (
id text primary key, -- pay_XXXXXXXXXXXXXXXX
merchant_id text not null references merchants(id),
status text not null check (status in ('pending','requires_action','authorized','captured','failed','canceled','expired')),
amount bigint not null check (amount > 0), -- minor units
currency char(3) not null,
captured_amount bigint not null default 0,
refunded_amount bigint not null default 0,
capture_method text not null check (capture_method in ('automatic','manual')),
idempotency_key text, -- null when the merchant sent none
failure_code text,
created_at timestamptz not null default now(),
authorized_at timestamptz,
captured_at timestamptz,
updated_at timestamptz not null default now()
);
create index payments_merchant_created_idx on payments (merchant_id, created_at desc);
create unique index payments_idempotency_idx on payments (merchant_id, idempotency_key) where idempotency_key is not null;
create table refunds (
id text primary key, -- re_XXXXXXXXXXXXXXXX
payment_id text not null references payments(id),
merchant_id text not null references merchants(id),
amount bigint not null check (amount > 0),
currency char(3) not null,
status text not null check (status in ('pending','succeeded','failed')),
reason text,
idempotency_key text,
request_id text, -- the API request that created it
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index refunds_payment_idx on refunds (payment_id, created_at);
create unique index refunds_idempotency_idx on refunds (merchant_id, idempotency_key) where idempotency_key is not null;
create table ledger_entries (
id text primary key, -- led_XXXXXXXXXXXXXXXX
payment_id text not null references payments(id),
merchant_id text not null,
kind text not null check (kind in ('capture','refund')),
amount bigint not null, -- positive for capture, negative for refund
currency char(3) not null,
source_event text not null, -- evt_ id of the Kafka event that produced it
posted_at timestamptz not null default now()
);
create index ledger_entries_payment_idx on ledger_entries (payment_id);
create table settlements (
id text primary key, -- set_XXXXXXXXXXXX
merchant_id text not null references merchants(id),
currency char(3) not null,
total_amount bigint not null,
status text not null check (status in ('open','paid')),
settles_on date not null,
created_at timestamptz not null default now()
);
create table settlement_items (
settlement_id text not null references settlements(id),
entry_id text not null references ledger_entries(id),
primary key (settlement_id, entry_id)
);
create table webhook_deliveries (
id text primary key,
merchant_id text not null references merchants(id),
event_id text not null,
event_type text not null,
payment_id text,
status text not null check (status in ('pending','delivered','failed','exhausted')),
attempts int not null default 0,
last_status_code int,
last_attempt_at timestamptz,
created_at timestamptz not null default now()
);
create index webhook_deliveries_payment_idx on webhook_deliveries (payment_id);
-- Every request to the Merchant API, retained 90 days. duration_ms is
-- measured inside the API service, after the gateway.
create table api_requests (
request_id text primary key, -- req_XXXXXXXXXXXXXXXX
merchant_id text,
method text not null,
path text not null,
status_code int not null,
duration_ms int not null,
idempotency_key text,
resource_id text, -- pay_ or re_ id the request created or touched
created_at timestamptz not null default now()
);
create index api_requests_merchant_created_idx on api_requests (merchant_id, created_at desc); Missions set in Northline Pay
Analyze a payment API
A hands-on lab for technical analysts: read a real OpenAPI contract against requirements, sample responses, and business rules, and find the gaps before a developer writes integration code.
Validate an event flow
A hands-on lab for technical analysts: follow one payment across Kafka topics, consumer logs, and a dead letter queue, and prove why the settlement event never arrived.
Investigate a production incident
A hands-on lab for technical analysts: work a duplicate refund incident from the ticket through API responses, request logs, database records, and monitoring, and separate the trigger from the defect.