>_ Analyst Engineering

01Read the ticket and write the expected flow

Turn the merchant's complaint and the platform architecture into a chain of events you can verify one link at a time.

The ticket says what the merchant sees, not what happened

Merchant tickets describe symptoms in the merchant’s vocabulary: a missing webhook, a payment not in a report. Your first job is to translate the symptom into the platform’s vocabulary: which event, on which topic, was supposed to trigger the thing that did not happen.

Read the ticket for three things: the payment identifier, the timeline the merchant gives, and what they did receive. What they did receive is as useful as what they did not. If the captured webhook arrived, then everything up to and including the captured event worked. That already rules out half the platform.

Write the chain before you look at any data

From the settlement flow and the event catalogue, write the expected chain for this payment as a numbered list. For each link: the event type, the topic, the producer, the consumer, and roughly when it should have happened relative to the capture. The chain is your checklist for the next step. Without it you will read the events looking for something wrong. With it you will read them looking for the first link that is missing, which is a different and faster kind of reading.

Decide the first check

There is one query that splits the problem in half. Decide what it is before you get the data. Usually it is: “does the event that should have triggered the missing thing exist on its topic?” If it exists, the problem is downstream of it. If it does not, the problem is upstream. Write both branches down.

Your task

Write the expected chain for one captured payment: each event, the topic it appears on, the service that produces it, and the service that consumes it. Then write the single first check you would run, and what each possible result would tell you.

Evidence for this step

Read it in the page or download it and open it in your own tools.

Mission artifact ticket.md 18 lines download show
# NLP-4821: payment.settled webhook never received

Reporter: support@maisonverte.example (Maison Verte marketplace, merchant `mer_A7K3P2Q9`)
Opened: 2026-09-12 16:41 UTC
Priority: P2 (finance blocked)
Assignee: platform support rotation

## Merchant's message

> Hi, our finance team is closing the week and one payment is missing from settlement. Payment `pay_5Rt8kM2xQ7wL9nB4`, captured on Wednesday 10 September around 09:15 UTC, 340.00 EUR. We received the `payment.authorized` and `payment.captured` webhooks a few seconds apart, both verified fine. According to your docs we should get `payment.settled` at T+2, so Friday. Nothing arrived, and the payment is not in the settlement report in the dashboard either. Our endpoint has been up all week, no errors on our side. Can you check what happened on yours?

## Support first-line notes

- Merchant dashboard: payment shows status `captured`, captured 2026-09-10 09:15:31 UTC, EUR 34000. Settlement: none.
- Webhook delivery log in the dashboard: 2 deliveries, both 200. No failed deliveries for this merchant this week.
- Checked `settlement.payment.settled` topic for the payment id: no event.
- Escalating to platform support for the event chain.
Mission artifact settlement-flow.md 27 lines download show
# Capture to settlement: the expected flow

Source: platform team runbook, section "Money flow". Applies to card payments with automatic or manual capture.

## Sequence for one payment

| Step | Event / action | Topic | Producer | Consumer | Expected timing |
|---|---|---|---|---|---|
| 1 | Payment authorized | `payments.payment.authorized` | orchestrator | webhook-dispatcher, risk | at authorization |
| 2 | Payment captured | `payments.payment.captured` | orchestrator | ledger, webhook-dispatcher | at capture (seconds after 1 for automatic capture) |
| 3 | Ledger entry posted (kind `capture`) | `ledger.entry.posted` | ledger | settlement | within 5 seconds of 2 |
| 4 | Payment included in an open settlement | (database only: `settlement_items`) | settlement | none | within 1 minute of 3 |
| 5 | Settlement paid, payment settled | `settlement.payment.settled` | settlement | webhook-dispatcher | T+2 business days, 06:00 UTC batch |
| 6 | `payment.settled` webhook delivered | (HTTP to merchant) | webhook-dispatcher | merchant | within minutes of 5 |

## Consumer behavior that matters

- The ledger validates every message against the schema in the event catalogue before processing. A message that fails validation is sent to `ledger.dlq` with the error in the headers. There is no automatic retry and no alert below 5,000 DLQ messages per hour.
- Settlement only knows about a payment through `ledger.entry.posted`. It never reads `payments.*` topics.
- The webhook dispatcher delivers what it consumes. It cannot deliver an event that was never produced.

## Where to look

- Kafka UI: search by key (the payment id) across `payments.*`, `ledger.*`, `settlement.*`. Dead letter topics are keyed by source offset, search them by payload.
- Logs: `ledger-service`, `settlement-service`, `webhook-dispatcher`, filter on `payment_id`.
- Database: `ledger_entries` and `settlement_items` by `payment_id`.
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
Next: Follow the events