>_ Analyst Engineering

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.

ServiceResponsibilityTalks to
Merchant APIThe public REST API merchants call. Validates requests, enforces idempotency, and returns payment and refund resources.Orchestrator (synchronous), PostgreSQL
OrchestratorRuns the payment lifecycle: authorization, 3-D Secure, capture, refund. Publishes every state change as an event.Acquirers (external), Kafka
LedgerConsumes captured and refunded events and posts double-entry ledger entries. Emits ledger.entry.posted.Kafka, PostgreSQL
SettlementConsumes ledger entries and groups them into T+2 settlements per merchant. Emits settlement.payment.settled.Kafka, PostgreSQL
Webhook dispatcherConsumes public events and delivers signed webhooks to merchant endpoints with retries.Kafka, merchant endpoints
RiskScores payments before authorization. Can decline.Orchestrator (synchronous)

The happy path of a captured card payment crosses all of them:

  1. The merchant calls POST /v1/payments. The Merchant API validates the request and calls the Orchestrator.
  2. The Orchestrator asks Risk for a score, then authorizes with the acquirer. It publishes payments.payment.authorized.
  3. On capture (automatic or POST /v1/payments/{id}/capture), the Orchestrator publishes payments.payment.captured.
  4. The Ledger consumes the captured event, posts the entries, and publishes ledger.entry.posted.
  5. Settlement consumes the ledger event and, at T+2, publishes settlement.payment.settled.
  6. The Webhook dispatcher consumes each public event and delivers payment.authorized, payment.captured, and payment.settled to 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). 12550 is 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 Z suffix.
  • 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: sandbox and production. 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