API and webhooks · Contracts, ownership and asynchronous effects

The 60-second model#

An API test checks an agreed service boundary: request/response, permissions, business invariants and observable effects. A webhook is a server-to-server event notification whose delivery and processing can be delayed, repeated or reordered. Neither HTTP 2xx nor “callback received” alone proves the intended payment effect.

HTTP semantics and useful negatives#

Method/code Meaning and test rule
GET Safe/idempotent intended semantics; test ownership and caching/privacy
POST Submission/create; do not assume retry-safe without a contract
PUT / PATCH Replacement intended idempotent / partial operation whose semantics may vary
DELETE Idempotent intended effect, not necessarily identical replay response
200 / 201 / 202 / 204 Success / created / accepted / no content; assert documented exact behavior
400 / 422 Invalid syntax/semantic validation where the service makes that distinction
401 / 403 / 404 Missing/invalid auth / forbidden / not found or concealed resource
409 / 429 Conflict / rate limit; don't silently retry writes
500 / 502 / 503 Internal/upstream/unavailable; distinguish from transport exception

Test required/omitted/null/wrong-type fields, integer amount bounds, unsupported currency, invalid transitions, duplicate keys, role/tenant boundaries, concurrency and side effects. .ok() accepts any 2xx; it cannot prove the payment is CAPTURED. A static DTO assertion does not validate JSON bytes.

A complete create/read/cleanup test#

typescript
// tests/api/payment.spec.ts. Hypothetical safe sandbox/test-support contracts.
import { test, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
test('created payment has the correct owned amount', async ({ request }, info) => {
  const correlation = randomUUID();
  const created = await request.post('/test-support/payments', {
    data: { amountMinor: 1250, currency: 'GEL', correlation },
  });
  expect(created.status()).toBe(201);
  const body: unknown = await created.json();
  if (!body || typeof body !== 'object' || !('id' in body) || typeof body.id !== 'string') {
    throw new Error('Expected payment id');
  }
  const id = encodeURIComponent(body.id);
  let failed = false;
  try {
    const response = await request.get(`/sandbox/payments/${id}`);
    expect(response.status()).toBe(200);
    expect(await response.json()).toMatchObject({ id: body.id, amountMinor: 1250, currency: 'GEL' });
  } catch (error) { failed = true; throw error; }
  finally {
    try {
      const deleted = await request.delete(`/test-support/payments/${id}`);
      expect(deleted.status()).toBe(204);
    } catch (error) {
      await info.attach('cleanup-failure', { body: String(error), contentType: 'text/plain' });
      if (!failed) throw error;
    }
  }
});

Assumes configured sandbox baseURL/auth and an authorized reset API; real financial records are not casually deleted. Creation may commit before its response is lost: correlation lookup/expiry is still needed to recover unknown owned records. Cleanup is attempted, not guaranteed during outages. Redact diagnostic attachments as needed.

Runtime shape, headers and permissions#

typescript
// Scoped inside a Playwright test with request/expect and isolated merchant fixtures.
const invalid = await request.post('/sandbox/payments', {
  data: { amountMinor: -1, currency: 'GEL' },
});
expect(invalid.status()).toBe(422); // hypothetical agreed error contract
expect(invalid.headers()['content-type']).toContain('application/json');
expect(await invalid.json()).toMatchObject({ code: 'INVALID_AMOUNT' });
typescript
// Standalone parser, deliberately narrow teaching contract.
export type Payment = { id: string; amountMinor: number; currency: string };
export function parsePayment(value: unknown): Payment {
  if (!value || typeof value !== 'object' || !('id' in value) ||
      typeof value.id !== 'string' || !value.id || !('amountMinor' in value) ||
      typeof value.amountMinor !== 'number' || !Number.isSafeInteger(value.amountMinor) ||
      !('currency' in value) || typeof value.currency !== 'string') throw new Error('Invalid payment');
  return { id: value.id, amountMinor: value.amountMinor, currency: value.currency };
}

Schema validity and business validity are separate: this parser allows a negative integer, so the creation/business-rule test must reject it where required. Add enum/range/ISO-currency checks according to actual requirements or an optional schema validator.

Create payment A as merchant A, then access it through a distinct context/token for B. Assert the specified deny response and no data/effect leakage. Also vary same-merchant read-only versus refund permission, expired tokens and malformed IDs. request is a direct HTTP context; a page.route mock does not intercept these calls.

Idempotency exercise#

typescript
// Scoped test body. Backend explicitly supports this header and replay policy.
const key = randomUUID(); // import from node:crypto
const payload = { amountMinor: 1250, currency: 'GEL', methodToken: sandboxToken };
const first = await request.post('/sandbox/payments', {
  headers: { 'Idempotency-Key': key }, data: payload,
});
expect(first.status()).toBe(201);
const original: { id: string } = await first.json();
const replay = await request.post('/sandbox/payments', {
  headers: { 'Idempotency-Key': key }, data: payload,
});
expect(replay.status()).toBe(200); // illustrative replay status, not a PSP standard
expect(await replay.json()).toMatchObject({ id: original.id });

Also verify one intended financial effect via a supported sandbox read, changed payload with same key, simultaneous repeats, key scoping by merchant/operation and documented retention window. Never assume one provider's status/cache/expiry rules apply to another. On timeout, reconcile by the supported original operation identity before replay.

Webhook risk matrix#

Input/failure Expected observation to agree and test
Valid signed success Correct tenant/payment/attempt updated; one logical effect
Invalid signature / modified raw body Rejected or safely ignored per contract; no effect
Repeated same event ID Acknowledged/deduplicated according to policy, no second effect
Same business fact with different event ID Business-level invariants still hold
Old pending/decline after success No illegal state regression; version/transition policy applied
Unknown payment/provider reference Safe response/quarantine/retry behavior and observable diagnosis
Delayed callback after initial timeout Original attempt reconciled under agreed policy
Handler crashes before/after persistence Retry/recovery without missing or duplicated effects
Stale timestamp / replay attack Provider-specific signature/time validation and deduplication
Provider redelivery after non-2xx Bounded recoverable processing, monitoring and traceable event identity

Verify signatures with the provider's supported library/protocol, including required raw-body handling. Do not replace it with an invented generic HMAC recipe. Timestamp tolerance, retries, ordering and accepted response codes are provider-specific. Acknowledge only according to the ingestion durability contract: acknowledging before any durable handoff can lose events if the process then crashes.

typescript
// Scoped test body: authorized signed-event simulator, not an OMNO endpoint.
const result = await request.post('/test-support/provider-events', {
  data: { paymentId: payment.id, eventId: event.id, status: 'CAPTURED', deliveries: 2 },
});
expect(result.status()).toBe(202);
await expect.poll(async () => {
  const read = await request.get(`/test-support/payments/${payment.id}/effects`);
  expect(read.status()).toBe(200);
  return (await read.json()).captureCount;
}, { timeout: 10_000, intervals: [100, 250, 500] }).toBe(1);

One observed count at one moment is not sufficient to prove no later duplicate: wait for the harness's all deliveries processed barrier or a documented quiescence/watermark, then assert final count. Test both ingestion acknowledgement and resulting business state. A route mock cannot exercise real webhook persistence.

Contract tests versus sandbox integration#

OpenAPI/schema tests check documented shape/status; consumer contracts check an interaction a caller relies on. Neither proves every deployed provider flow. Use controlled provider simulations for rare timeouts/orderings, a small real PSP sandbox suite for actual integration, and UI tests for rendering/user flows. Postman/Swagger support manual discovery; Playwright integrates API/UI fixtures and CI reporting. Test selection follows risk, not tool loyalty.

Interview answer and references#

30 seconds: “I test API status, runtime body, permissions and business effects. For payments I add duplicate/concurrent submission and uncertain-outcome handling. For webhooks I cover signature validation, duplicate and out-of-order delivery, durable ingestion and final state, using authorized simulators plus a focused real sandbox integration suite.”

90-second answer: why is a successful webhook response insufficient?

The sender seeing 2xx only proves the endpoint acknowledged according to its contract. Processing may be queued, persistence may fail later, or the event may target the wrong payment. I check the signed payload and event/provider identity, then follow durable handoff and completion evidence. I replay the same event and the same business fact under different event IDs, deliver old events after newer ones and inject safe crashes around the persistence boundary. The expected result is a legal state and one intended effect, with traceable recovery. Provider-specific signature/timestamp/retry behavior comes from its documentation, not a universal assumption. I preserve only sanitized diagnostics and avoid production payment effects.

Memory hook: response + identity + shape + permission + effect. References: Playwright API testing, Stripe webhook guidance, one provider example, HTTP semantics. Next: TypeScript.

Quick practice#

Is idempotency just adding a header?
  • No, the backend must enforce a scoped key/payload/retention contract
  • Test same operation, mismatched payload, concurrency and one effect.

Pitfall: Assuming provider and orchestration keys share one dedupe boundary.

Full explanation.

What should an API test assert?
  • Documented status, runtime shape, permissions, business rules and effects
  • Cover boundaries, negative states, duplicates and cleanup.

Pitfall: Only checking response.ok().

Full explanation.

401 versus 403 versus concealed 404?
  • 401 generally concerns absent/invalid authentication, 403 forbidden action and 404 absent/concealed resource
  • Follow the actual security policy and test cross-merchant behavior.

Pitfall: Token presence proves authorization.

Full explanation.

Mid-Level QA Automation Engineer — Payment Orchestration · CV refreshed 2026-10-16. Prepared from reviewed role-specific sources.