Testing external API integrations end-to-end

The app we’re testing

Here’s an e-commerce platform with a fairly standard checkout flow. A customer browses products, adds items to a cart, and pays. Behind the scenes, several services and external APIs are involved:

The data model:

CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  email VARCHAR(200) NOT NULL,
  phone VARCHAR(20),
  name VARCHAR(100) NOT NULL
);

CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  sku VARCHAR(50) UNIQUE NOT NULL,
  name VARCHAR(200) NOT NULL,
  price_cents INTEGER NOT NULL,
  stock INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  customer_id INTEGER REFERENCES customers(id),
  status VARCHAR(20) DEFAULT 'pending',
  total_cents INTEGER NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE order_items (
  order_id INTEGER REFERENCES orders(id),
  product_id INTEGER REFERENCES products(id),
  quantity INTEGER NOT NULL,
  unit_price_cents INTEGER NOT NULL,
  PRIMARY KEY (order_id, product_id)
);

When a customer clicks “Pay,” the chain looks like this: API gateway → order service (creates order) → payment service (charges Stripe) → order service (marks as paid) → shipping service (creates label via EasyPost) → notification service (emails via SendGrid + texts via Twilio). If any of those external APIs fail, the order needs to be handled gracefully — and that’s exactly what’s hard to test without Dokkimi.

Seeding test data

Start with a seed file that gives you products, a customer, and a known starting state:

-- .dokkimi/ecommerce/init/seed.sql

INSERT INTO customers (id, email, phone, name) VALUES
  (1, 'buyer@example.com', '+15551234567', 'Jane Doe');

INSERT INTO products (id, sku, name, price_cents, stock) VALUES
  (1, 'WIDGET-001', 'Blue Widget', 999, 50),
  (2, 'WIDGET-002', 'Red Widget', 1499, 30),
  (3, 'GADGET-001', 'Turbo Gadget', 4999, 5);

SELECT setval('customers_id_seq', 10);
SELECT setval('products_id_seq', 10);
SELECT setval('orders_id_seq', 10);

This gives you a customer with a known email and phone (for notification assertions), products with known prices (for payment assertions), and limited stock on the Turbo Gadget (for testing out-of-stock scenarios later).

Building the mock library

Each external API gets a shared mock file. These live in .dokkimi/shared/ so every test can reference them:

# .dokkimi/shared/mock-stripe-success.yaml
type: MOCK
name: mock-stripe
mockTarget: api.stripe.com
mockPath: /v1/charges
mockResponseStatus: 200
mockResponseHeaders:
  content-type: application/json
mockResponseBody:
  id: ch_test_123
  object: charge
  status: succeeded
  amount: 2498
  currency: usd
# .dokkimi/shared/mock-easypost-success.yaml
type: MOCK
name: mock-easypost
mockTarget: api.easypost.com
mockPath: /v2/shipments
mockResponseStatus: 201
mockResponseHeaders:
  content-type: application/json
mockResponseBody:
  id: shp_test_789
  tracking_code: '9400111899223456789012'
  status: pre_transit
  postage_label:
    label_url: 'https://easypost.com/labels/test-label.pdf'
# .dokkimi/shared/mock-sendgrid-success.yaml
type: MOCK
name: mock-sendgrid
mockTarget: api.sendgrid.com
mockPath: /v3/mail/send
mockResponseStatus: 202
mockResponseHeaders:
  content-type: application/json
mockResponseBody: {}
# .dokkimi/shared/mock-twilio-success.yaml
type: MOCK
name: mock-twilio
mockTarget: api.twilio.com
mockPath: /2010-04-01/Accounts/*/Messages.json
mockResponseStatus: 201
mockResponseHeaders:
  content-type: application/json
mockResponseBody:
  sid: SM_test_456
  status: queued

The Twilio mock uses a wildcard (*) in the path to match any Account SID.

Testing the happy path

With mocks and seed data in place, write a test that exercises the full checkout:

name: checkout-happy-path
items:
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/order-service.yaml
  - $ref: ../shared/payment-service.yaml
  - $ref: ../shared/shipping-service.yaml
  - $ref: ../shared/notification-service.yaml
  - $ref: ../shared/postgres-db.yaml
  - $ref: ../shared/mock-stripe-success.yaml
  - $ref: ../shared/mock-easypost-success.yaml
  - $ref: ../shared/mock-sendgrid-success.yaml
  - $ref: ../shared/mock-twilio-success.yaml

steps:
  # Create an order
  - action:
      type: http
      method: POST
      url: api-gateway/v1/orders
      body:
        customerId: 1
        items:
          - sku: WIDGET-001
            quantity: 1
          - sku: WIDGET-002
            quantity: 1
    assertions:
      - target: self
        assertions:
          - type: response.statusCode
            operator: eq
            value: 201
          - type: response.body
            path: $.order.totalCents
            operator: eq
            value: 2498
    extract:
      orderId: response.body.order.id

  # Pay for the order
  - action:
      type: http
      method: POST
      url: api-gateway/v1/orders/{{orderId}}/pay
      body:
        paymentMethod: pm_test_visa
    assertions:
      # Stripe was charged the correct amount
      - target: httpCall
        match:
          origin: payment-service
          method: POST
          url: api.stripe.com/v1/charges
        assertions:
          - type: request.body
            path: $.amount
            operator: eq
            value: 2498
          - type: request.body
            path: $.currency
            operator: eq
            value: usd

      # A shipping label was created
      - target: httpCall
        match:
          origin: shipping-service
          method: POST
          url: api.easypost.com/v2/shipments
        assertions:
          - type: response.body
            path: $.tracking_code
            operator: exists

      # Confirmation email was sent to the customer
      - target: httpCall
        match:
          origin: notification-service
          method: POST
          url: api.sendgrid.com/v3/mail/send
        assertions:
          - type: request.body
            path: $.personalizations[0].to[0].email
            operator: eq
            value: buyer@example.com

      # SMS receipt was sent
      - target: httpCall
        match:
          origin: notification-service
          method: POST
          url: api.twilio.com
        assertions:
          - type: request.body
            path: $.To
            operator: eq
            value: '+15551234567'
          - type: request.body
            path: $.Body
            operator: contains
            value: '9400111899223456789012'

  # Verify the order was persisted correctly
  - action:
      type: database
      service: postgres-db
      query: "SELECT status, total_cents FROM orders WHERE id = {{orderId}}"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].status
            operator: eq
            value: paid
          - type: response.body
            path: $.rows[0].total_cents
            operator: eq
            value: 2498

  # Verify stock was decremented
  - action:
      type: database
      service: postgres-db
      query: "SELECT stock FROM products WHERE sku = 'WIDGET-001'"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].stock
            operator: eq
            value: 49

One test covers the entire flow: order creation, payment processing, shipping label generation, email confirmation, SMS receipt, database persistence, and inventory management. Every assertion is deterministic because the seed data has known prices, stock levels, and customer contact info.

Testing error handling

Error scenarios are where mocks really pay off. Create mock variants for each failure case:

# .dokkimi/shared/mock-stripe-card-declined.yaml
type: MOCK
name: mock-stripe
mockTarget: api.stripe.com
mockPath: /v1/charges
mockResponseStatus: 402
mockResponseHeaders:
  content-type: application/json
mockResponseBody:
  error:
    type: card_error
    code: card_declined
    message: Your card was declined.
    decline_code: generic_decline

Now test that a declined card is handled correctly across the entire system:

name: checkout-card-declined
items:
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/order-service.yaml
  - $ref: ../shared/payment-service.yaml
  - $ref: ../shared/notification-service.yaml
  - $ref: ../shared/postgres-db.yaml
  - $ref: ../shared/mock-stripe-card-declined.yaml
  - $ref: ../shared/mock-twilio-success.yaml
  - $ref: ../shared/mock-sendgrid-success.yaml

steps:
  - action:
      type: http
      method: POST
      url: api-gateway/v1/orders
      body:
        customerId: 1
        items:
          - sku: WIDGET-001
            quantity: 1
    extract:
      orderId: response.body.order.id

  - action:
      type: http
      method: POST
      url: api-gateway/v1/orders/{{orderId}}/pay
      body:
        paymentMethod: pm_test_visa
    assertions:
      # Payment was rejected
      - target: self
        assertions:
          - type: response.statusCode
            operator: eq
            value: 402
          - type: response.body
            path: $.error
            operator: contains
            value: declined

      # No shipping label was created (if no matching HTTP call was
      # observed during the step, a doesNotExist assertion passes)
      - target: httpCall
        match:
          origin: shipping-service
          url: api.easypost.com
        assertions:
          - type: response.statusCode
            operator: doesNotExist

      # No confirmation email was sent
      - target: httpCall
        match:
          origin: notification-service
          url: api.sendgrid.com
        assertions:
          - type: response.statusCode
            operator: doesNotExist

  # Order status should be payment_failed, not paid
  - action:
      type: database
      service: postgres-db
      query: "SELECT status FROM orders WHERE id = {{orderId}}"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].status
            operator: eq
            value: payment_failed

  # Stock should NOT have been decremented
  - action:
      type: database
      service: postgres-db
      query: "SELECT stock FROM products WHERE sku = 'WIDGET-001'"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].stock
            operator: eq
            value: 50

This test verifies four critical behaviors: the error response is correct, no shipping label was created, no confirmation was sent, and the order was marked as failed. It also checks that stock wasn’t decremented — a subtle bug where the inventory update happens before the payment check.

Testing webhook callbacks

Stripe uses webhooks to notify your services asynchronously. After a charge succeeds, Stripe sends a charge.succeeded event to your webhook endpoint. Many payment flows rely on this rather than the synchronous charge response.

You can simulate webhooks by sending the HTTP request yourself. First, seed an order in the payment_pending state:

name: stripe-webhook-charge-succeeded
items:
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/order-service.yaml
  - $ref: ../shared/payment-service.yaml
  - $ref: ../shared/shipping-service.yaml
  - $ref: ../shared/notification-service.yaml
  - $ref: ../shared/postgres-db.yaml
  - $ref: ../shared/mock-easypost-success.yaml
  - $ref: ../shared/mock-sendgrid-success.yaml
  - $ref: ../shared/mock-twilio-success.yaml

steps:
  # Seed an order that's waiting for payment confirmation
  - action:
      type: database
      service: postgres-db
      query: >
        INSERT INTO orders (id, customer_id, status, total_cents)
        VALUES (100, 1, 'payment_pending', 2498)

  - action:
      type: database
      service: postgres-db
      query: >
        INSERT INTO order_items (order_id, product_id, quantity, unit_price_cents)
        VALUES (100, 1, 1, 999), (100, 2, 1, 1499)

  # Simulate the Stripe webhook
  - action:
      type: http
      method: POST
      url: payment-service/webhooks/stripe
      headers:
        stripe-signature: 'whsec_test_signature'
        content-type: application/json
      body:
        id: evt_test_001
        type: charge.succeeded
        data:
          object:
            id: ch_test_123
            amount: 2498
            status: succeeded
            metadata:
              order_id: '100'
    assertions:
      - target: self
        assertions:
          - type: response.statusCode
            operator: eq
            value: 200

      # Order was marked as paid
      - target: httpCall
        match:
          origin: payment-service
          method: PATCH
          url: order-service/v1/orders/100
        assertions:
          - type: request.body
            path: $.status
            operator: eq
            value: paid

      # Shipping was initiated
      - target: httpCall
        match:
          origin: shipping-service
          method: POST
          url: api.easypost.com/v2/shipments
        assertions:
          - type: response.statusCode
            operator: eq
            value: 201

      # Customer was notified
      - target: httpCall
        match:
          origin: notification-service
          method: POST
          url: api.sendgrid.com/v3/mail/send
        assertions:
          - type: request.body
            path: $.personalizations[0].to[0].email
            operator: eq
            value: buyer@example.com

  # Verify final state in the database
  - action:
      type: database
      service: postgres-db
      query: "SELECT status FROM orders WHERE id = 100"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].status
            operator: eq
            value: paid

You’re testing the entire asynchronous flow: Stripe fires a webhook, your payment service processes it, updates the order, triggers shipping, and sends notifications. Every step is verified through both HTTP assertions and a final database check.

Testing rate limits and slow responses

Third-party APIs sometimes respond slowly or rate-limit you. Does your service handle that gracefully?

# .dokkimi/shared/mock-stripe-rate-limited.yaml
type: MOCK
name: mock-stripe
mockTarget: api.stripe.com
mockPath: /v1/charges
mockResponseStatus: 429
mockResponseHeaders:
  content-type: application/json
  retry-after: '2'
mockResponseBody:
  error:
    type: rate_limit_error
    message: Too many requests.
# .dokkimi/shared/mock-stripe-slow.yaml
type: MOCK
name: mock-stripe
mockTarget: api.stripe.com
mockPath: /v1/charges
mockResponseStatus: 200
mockResponseDelay: 5000
mockResponseBody:
  id: ch_test_123
  status: succeeded
  amount: 2498

If your payment service has a 3-second timeout, the slow mock triggers it. You can assert that the service retries, returns a timeout error to the client, or queues the charge for later — whatever your retry policy dictates.

The rate limit mock lets you verify that your service respects the Retry-After header. Does it wait and retry, or does it fail immediately and return an error to the user?

Testing out of stock

Here’s a scenario that doesn’t involve external APIs at all but benefits from the same seeded database approach. The Turbo Gadget has only 5 in stock:

name: out-of-stock-rejection
items:
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/order-service.yaml
  - $ref: ../shared/postgres-db.yaml

steps:
  - action:
      type: http
      method: POST
      url: api-gateway/v1/orders
      body:
        customerId: 1
        items:
          - sku: GADGET-001
            quantity: 10
    assertions:
      - target: self
        assertions:
          - type: response.statusCode
            operator: eq
            value: 409
          - type: response.body
            path: $.error
            operator: contains
            value: 'insufficient stock'

  # Stock should be unchanged
  - action:
      type: database
      service: postgres-db
      query: "SELECT stock FROM products WHERE sku = 'GADGET-001'"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].stock
            operator: eq
            value: 5

Because the seed file set the Turbo Gadget’s stock to 5, you can test the boundary condition deterministically. No test flakiness from shared databases, no wondering what the stock level was when the test started.

Organizing your test suite

As your test suite grows, organize by concern:

.dokkimi/
  shared/
    api-gateway.yaml
    order-service.yaml
    payment-service.yaml
    shipping-service.yaml
    notification-service.yaml
    postgres-db.yaml
    mock-stripe-success.yaml
    mock-stripe-card-declined.yaml
    mock-stripe-rate-limited.yaml
    mock-stripe-slow.yaml
    mock-easypost-success.yaml
    mock-sendgrid-success.yaml
    mock-twilio-success.yaml
  ecommerce/
    init/
      seed.sql
    definitions/
      checkout-happy-path.yaml
      checkout-card-declined.yaml
      checkout-rate-limited.yaml
      checkout-out-of-stock.yaml
      webhook-charge-succeeded.yaml
      webhook-charge-failed.yaml

The shared/ directory has one file per service and one mock per external-API-scenario. The test definitions in ecommerce/definitions/ reference them via $ref and focus on the test steps and assertions. When you add a new external API integration, you add a few mock files and a few test definitions — the services are already defined.

Tips