Confirm an order without a duplicate retry

Persist the business event before the request and reuse its provider key after recoverable failures.

Local mock Version 1.0.0 · Node.js 22.23.2 / TypeScript · .md

Local fixtures, not a delivery claim.Local fixtures on Node.js 22.23.2 and contract review; authenticated sandbox and delivery not executed.

The public site does not accept credentials or send email. Authenticated use happens only in your own backend.

Before you start

  • Node.js 22.23.2 with the built-in experimental node:sqlite module.
  • A dedicated private data directory: the starter creates .1loc-data with mode 0700 and SQLite files with mode 0600. Existing custom data directories must already be private.
  • For optional sandbox use: a verified sender and a backend API key with send scope.

Run the local example

  1. Run npm run mock twice. The second run reads the saved event and makes no API request.
  2. Run npm test to check restart persistence, concurrent claims, changed payload rejection and the reconciliation cutoff.
  3. Use a real application event id when adapting this starter. Store the original payload and stable key before networking.
  4. After an ambiguous result, stop competing workers, reconcile the message in the API/dashboard, and only then explicitly resume with --resume-after-reconciliation inside the 23-hour safety cutoff.

Expected result: The first mock run records an acknowledgement. Repeating the same completed event returns applicationDuplicate: true, even after restarting the process.

The complete files

These exact files are included in the starter. Test fixtures use synthetic addresses and mock identifiers.

README.md
README.md
# Confirm an order without a duplicate retry

Maintained by Oveyon. MIT licensed. Version 1.0.0.
Canonical: https://1loc.dev/recipes/order-confirmation/

Persist the business event before the request and reuse its provider key after recoverable failures.

## Run locally

Node.js 22.23.2. No install is needed; there are no external dependencies.

```sh
npm run mock
npm test
```

These commands do not send email or make API requests. SQLite, when used, is experimental in Node 22.

## Prerequisites

- Node.js 22.23.2 with the built-in experimental node:sqlite module.
- A dedicated private data directory: the starter creates .1loc-data with mode 0700 and SQLite files with mode 0600. Existing custom data directories must already be private.
- For optional sandbox use: a verified sender and a backend API key with send scope.

## Procedure

1. Run npm run mock twice. The second run reads the saved event and makes no API request.
2. Run npm test to check restart persistence, concurrent claims, changed payload rejection and the reconciliation cutoff.
3. Use a real application event id when adapting this starter. Store the original payload and stable key before networking.
4. After an ambiguous result, stop competing workers, reconcile the message in the API/dashboard, and only then explicitly resume with --resume-after-reconciliation inside the 23-hour safety cutoff.

## Expected result

The first mock run records an acknowledgement. Repeating the same completed event returns applicationDuplicate: true, even after restarting the process.

## Permissions and effects

Mock mode writes a local SQLite file. Optional sandbox needs send scope. Manual reconciliation and resume are application-operator actions.

Optional backend command(s): npm run sandbox.
Keep credentials in .env on your own backend or in a secrets manager. Never commit .env. The public site does not accept credentials.

## Failures

- event_payload_changed: the same event was reused for different content; investigate instead of generating a new provider key.
- needs_reconciliation or already_processing: another request claimed the event, or a result remains uncertain. Do not start a competing send.
- reconciliation_window_expired: this sample refuses automatic resends beyond 23 hours, before the provider 24-hour deduplication window can expire.

## Limits and verification

- SQLite and the sample schema belong to the downloaded backend example; the public website has no database.
- This is not an exactly-once transport. A crash after provider acceptance and before the local commit requires reconciliation.
- Operator resume assumes no old worker is still running; a distributed queue, leases, full order-system integration and long-term retention policy are outside this starter.
- Mock and sandbox use separate default database files. Sandbox still consumes quota and never delivers. No authenticated API call was executed.
- The database helper keeps process umask 077 so later WAL/SHM creation stays private; it never changes permissions on a shared parent directory.

Local fixture tests executed on Node.js 22.23.2 on 2026-10-28. Contract snapshot reviewed; authenticated API/sandbox and real delivery were not executed.

## Sources

- https://api.oveyon.com/
- https://api.oveyon.com/v1/openapi.json
package.json
package.json
{
  "name": "1loc-order-confirmation",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "license": "MIT",
  "engines": {
    "node": ">=22.23.2 <23"
  },
  "scripts": {
    "mock": "OVEYON_MODE=mock node --env-file-if-exists=.env --experimental-strip-types order-confirmation/main.ts",
    "test": "node --experimental-strip-types --test order-confirmation/recipe.test.ts",
    "sandbox": "OVEYON_MODE=sandbox node --env-file-if-exists=.env --experimental-strip-types order-confirmation/main.ts"
  }
}
package-lock.json
package-lock.json
{
  "name": "1loc-order-confirmation",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "1loc-order-confirmation",
      "version": "1.0.0",
      "license": "MIT",
      "engines": {
        "node": ">=22.23.2 <23"
      }
    }
  }
}
.env.example
.env.example
# Backend only. npm run mock never uses these values.
OVEYON_API_KEY=replace_with_a_server_side_key
OVEYON_FROM=Sender <[email protected]>
[email protected]
.gitignore
.gitignore
.env
.1loc-data/
*.sqlite
*.sqlite-shm
*.sqlite-wal
node_modules/
LICENSE
LICENSE
MIT License

Copyright (c) 2026 Oveyon

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
order-confirmation/main.ts
order-confirmation/main.ts
import { pathToFileURL } from 'node:url';
import { cliOptions, messageAddresses } from '../shared/client.ts';
import { mockApi } from '../shared/mock.ts';
import { confirmOrder, openOutbox, resumeAfterReconciliation } from './outbox.ts';
export async function run() {
  const options = cliOptions(mockApi().transport);
  // Separate stores prevent a synthetic acknowledgement from masking a sandbox request.
  const db = openOutbox(process.env.OUTBOX_PATH ?? `.1loc-data/orders-${options.mode}.sqlite`);
  try {
    if (process.argv.includes('--resume-after-reconciliation')) resumeAfterReconciliation(db, 'order:demo-001:confirmed');
    const result = await confirmOrder(db, 'order:demo-001:confirmed', { ...messageAddresses(), subject: 'Order demo-001 confirmed', text: 'Synthetic fixture: your order is recorded.' }, options);
    console.log(JSON.stringify({ mode: options.mode, result, meaning: 'Saved API acknowledgement, not a delivery receipt' }, null, 2));
  } finally { db.close(); }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await run();
order-confirmation/outbox.ts
order-confirmation/outbox.ts
import type { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { openPrivateDatabase } from '../shared/private-store.ts';
import { sendMessage } from '../shared/client.ts';
import type { SendPayload, Options } from '../shared/client.ts';
type OrderRow = { event_id: string; payload: string; state: string; created_at: number; result: string | null };
export function openOutbox(path: string) {
  const db = openPrivateDatabase(path);
  db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS orders (event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, state TEXT NOT NULL, created_at INTEGER NOT NULL, result TEXT);');
  return db;
}
export async function confirmOrder(db: DatabaseSync, eventId: string, payload: SendPayload, options: Omit<Options, 'idempotencyKey'>, now = Date.now()) {
  if (!/^[a-z0-9:-]{1,120}$/.test(eventId)) throw new TypeError('Use a stable, lowercase application event id.');
  const serialized = JSON.stringify(payload);
  db.prepare('INSERT OR IGNORE INTO orders(event_id,payload,state,created_at) VALUES(?,?,?,?)').run(eventId, serialized, 'pending', now);
  const row = db.prepare('SELECT * FROM orders WHERE event_id=?').get(eventId) as OrderRow;
  if (row.payload !== serialized) throw new Error('event_payload_changed: one event cannot refer to two different messages.');
  if (row.state === 'complete') return { ...JSON.parse(row.result!), applicationDuplicate: true };
  if (row.state !== 'pending') throw new Error('needs_reconciliation: stop other workers and inspect the stored event before an explicit resume.');
  // Stop before the provider's 24-hour dedup window, allowing clock/network margin.
  if (now - row.created_at > 23 * 60 * 60 * 1000) throw new Error('reconciliation_window_expired: do not blindly send again.');
  const claimed = db.prepare("UPDATE orders SET state='processing' WHERE event_id=? AND state='pending'").run(eventId);
  if (claimed.changes !== 1) throw new Error('already_processing');
  const idempotencyKey = `1loc:order:${createHash('sha256').update(eventId).digest('hex')}`;
  try {
    const result = await sendMessage(payload, { ...options, idempotencyKey });
    db.prepare("UPDATE orders SET state='complete',result=? WHERE event_id=?").run(JSON.stringify(result), eventId);
    return { ...result, applicationDuplicate: false };
  } catch (error) {
    db.prepare("UPDATE orders SET state='uncertain' WHERE event_id=?").run(eventId);
    throw error;
  }
}
export function resumeAfterReconciliation(db: DatabaseSync, eventId: string, now = Date.now()) {
  const row = db.prepare('SELECT * FROM orders WHERE event_id=?').get(eventId) as OrderRow | undefined;
  if (!row || row.state === 'complete') throw new Error('No unresolved event to resume.');
  if (now - row.created_at > 23 * 60 * 60 * 1000) throw new Error('reconciliation_window_expired');
  // Operator must first confirm no other worker is running and reconcile the API result.
  db.prepare("UPDATE orders SET state='pending' WHERE event_id=?").run(eventId);
}
order-confirmation/recipe.test.ts
order-confirmation/recipe.test.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { confirmOrder, openOutbox } from './outbox.ts';
import { mockApi } from '../shared/mock.ts';
const payload = { from: '[email protected]', to: '[email protected]', subject: 'Order fixture', text: 'Synthetic order.' };
test('completed event survives restart and avoids a second API request', async () => {
  const directory = mkdtempSync(join(tmpdir(), '1loc-order-'));
  const path = join(directory, 'outbox.sqlite'); const api = mockApi();
  try {
    let db = openOutbox(path);
    const first = await confirmOrder(db, 'order:fixture:confirmed', payload, { apiKey: 'fixture', transport: api.transport }); db.close();
    db = openOutbox(path);
    try {
      const repeated = await confirmOrder(db, 'order:fixture:confirmed', payload, { apiKey: 'fixture', transport: api.transport });
      assert.equal(first.id, repeated.id); assert.equal(repeated.applicationDuplicate, true); assert.equal(api.requests.length, 1);
      await assert.rejects(confirmOrder(db, 'order:fixture:confirmed', { ...payload, text: 'changed' }, { apiKey: 'fixture', transport: api.transport }), /event_payload_changed/);
    } finally { db.close(); }
  } finally { rmSync(directory, { recursive: true, force: true }); }
});
test('concurrent application requests claim one durable event', async () => {
  const db = openOutbox(':memory:'); const api = mockApi();
  try {
    const results = await Promise.allSettled([confirmOrder(db, 'order:fixture', payload, { apiKey: 'fixture', transport: api.transport }), confirmOrder(db, 'order:fixture', payload, { apiKey: 'fixture', transport: api.transport })]);
    assert.equal(results.filter(r => r.status === 'fulfilled').length, 1); assert.equal(api.requests.length, 1);
  } finally { db.close(); }
});
test('ambiguous outcomes and old pending events require reconciliation', async () => {
  const db = openOutbox(':memory:');
  try {
    await assert.rejects(confirmOrder(db, 'order:uncertain', payload, { apiKey: 'fixture', attempts: 1, transport: async () => { throw new Error('timeout'); } }, 1000));
    await assert.rejects(confirmOrder(db, 'order:uncertain', payload, { apiKey: 'fixture', transport: mockApi().transport }, 2000), /needs_reconciliation/);
    db.prepare('INSERT INTO orders(event_id,payload,state,created_at) VALUES(?,?,?,?)').run('order:old', JSON.stringify(payload), 'pending', 1000);
    await assert.rejects(confirmOrder(db, 'order:old', payload, { apiKey: 'fixture', transport: mockApi().transport }, 1000 + 24 * 3600 * 1000), /reconciliation_window_expired/);
  } finally { db.close(); }
});
test('outbox keeps new and existing DB/WAL/SHM private even with an initial umask 022', () => {
  const directory = mkdtempSync(join(tmpdir(), '1loc-private-order-'));
  const data = join(directory, 'private-data'); const path = join(data, 'orders.sqlite');
  const previousMask = process.umask(0o022);
  try {
    let db = openOutbox(path);
    db.prepare('INSERT INTO orders(event_id,payload,state,created_at) VALUES(?,?,?,?)').run('order:private', JSON.stringify(payload), 'pending', 1);
    assert.equal(statSync(data).mode & 0o777, 0o700);
    for (const file of [path, `${path}-wal`, `${path}-shm`]) {
      assert.equal(existsSync(file), true); assert.equal(statSync(file).mode & 0o777, 0o600); chmodSync(file, 0o644);
    }
    const second = openOutbox(path);
    try {
      for (const file of [path, `${path}-wal`, `${path}-shm`]) assert.equal(statSync(file).mode & 0o777, 0o600);
      assert.equal(second.prepare('SELECT event_id FROM orders').get()!.event_id, 'order:private');
    } finally { second.close(); db.close(); }
    const shared = join(directory, 'shared'); mkdirSync(shared); chmodSync(shared, 0o755);
    assert.throws(() => openOutbox(join(shared, 'orders.sqlite')), /dedicated data directory/);
    assert.equal(statSync(shared).mode & 0o777, 0o755);
  } finally { process.umask(previousMask); rmSync(directory, { recursive: true, force: true }); }
});
shared/client.ts
shared/client.ts
// MIT License — Copyright (c) 2026 Oveyon.
export type Transport = (url: string, init: RequestInit) => Promise<Response>;
export type SendPayload = { from: string; to: string; subject?: string; text?: string; templateId?: string; version?: number; data?: Record<string, unknown> };
export type Options = { apiKey: string; idempotencyKey: string; transport?: Transport; wait?: (ms: number) => Promise<void>; attempts?: number };

export class ApiError extends Error {
  status: number;
  code: string;
  retryable: boolean;
  constructor(status: number, code: string, retryable: boolean) {
    super(`Oveyon request failed: ${status} ${code}`);
    this.status = status; this.code = code; this.retryable = retryable;
  }
}

export function requestForSend(payload: SendPayload, apiKey: string, key: string) {
  if (!apiKey || /[\r\n]/.test(apiKey)) throw new TypeError('A server-side API key is required.');
  if (!/^[\x21-\x7e]{1,190}$/.test(key)) throw new TypeError('Idempotency key must be 1–190 printable ASCII characters without spaces.');
  if (!payload.from || !payload.to) throw new TypeError('from and to are required.');
  if (payload.templateId && (payload.subject !== undefined || payload.text !== undefined)) throw new TypeError('A template cannot be combined with subject/text.');
  if (!payload.templateId && (!payload.subject || !payload.text)) throw new TypeError('subject and text are required by this example.');
  return {
    url: 'https://api.oveyon.com/v1/send',
    init: { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': key, 'X-Oveyon-Sandbox': '1' }, body: JSON.stringify(payload), redirect: 'error' as const },
  };
}

export async function sendMessage(payload: SendPayload, options: Options) {
  const request = requestForSend(payload, options.apiKey, options.idempotencyKey);
  const transport = options.transport ?? fetch;
  const wait = options.wait ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
  const attempts = options.attempts ?? 3;
  if (!Number.isInteger(attempts) || attempts < 1 || attempts > 3) throw new TypeError('Use 1–3 attempts.');
  for (let attempt = 0; attempt < attempts; attempt++) {
    let response: Response;
    try {
      response = await transport(request.url, { ...request.init, signal: AbortSignal.timeout(10_000) });
    } catch {
      if (attempt + 1 === attempts) throw new ApiError(0, 'network_result_unknown', true);
      await wait(1000 * 2 ** attempt);
      continue;
    }
    let result: any;
    try { result = await response.json(); }
    catch { throw new ApiError(response.status, 'invalid_response', false); }
    if (response.status === 200 && result.status === 'duplicate' && typeof result.id === 'string') return result;
    if (response.status === 202 && ['accepted', 'sandbox', 'held'].includes(result.status) && typeof result.id === 'string') return result;
    if (response.ok) throw new ApiError(response.status, 'unexpected_response', false);
    const code = typeof result.error === 'string' ? result.error : 'unknown_error';
    const retryable = response.status === 429 || response.status >= 500 || (response.status === 409 && code === 'idempotency_in_progress');
    const error = new ApiError(response.status, code, retryable);
    if (!retryable || attempt + 1 === attempts) throw error;
    const retryAfter = response.headers.get('Retry-After');
    const advertised = retryAfter === null ? NaN : /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
    const delay = Number.isFinite(advertised) ? Math.max(0, advertised) : 1000 * 2 ** attempt;
    // Long waits belong in a durable job queue. Do not retry earlier than requested.
    if (delay > 30_000) throw error;
    await wait(delay);
  }
  throw new Error('Unreachable attempt state.');
}

export function cliOptions(transport: Transport) {
  const mode = process.env.OVEYON_MODE ?? 'mock';
  if (!['mock', 'sandbox'].includes(mode)) throw new TypeError('OVEYON_MODE supports mock or sandbox only.');
  if (mode === 'sandbox' && !process.env.OVEYON_API_KEY) throw new TypeError('Set OVEYON_API_KEY on the backend before an explicit sandbox run.');
  return { mode, apiKey: mode === 'mock' ? 'mock-key-not-a-credential' : process.env.OVEYON_API_KEY!, transport: mode === 'mock' ? transport : fetch };
}

export function messageAddresses() {
  return { from: process.env.OVEYON_FROM ?? 'Demo <[email protected]>', to: process.env.OVEYON_TO ?? '[email protected]' };
}
shared/mock.ts
shared/mock.ts
// Synthetic, in-memory API fixture. No HTTP request or email is sent.
import type { Transport } from './client.ts';
export function mockApi(failures: number[] = []) {
  const requests: { url: string; init: RequestInit }[] = [];
  const messages = new Map<string, { body: string; id: string }>();
  const templates = new Map<string, { version: number; published: boolean }>();
  const transport: Transport = async (url, init) => {
    requests.push({ url, init });
    const forced = failures.shift();
    if (forced) return Response.json({ error: forced === 429 ? 'rate_limited' : 'injection_failed' }, { status: forced, headers: { 'Retry-After': '0' } });
    const route = new URL(url).pathname;
    const data = JSON.parse(String(init.body ?? '{}'));
    if (route.endsWith('/templates')) {
      if (templates.has(data.name)) return Response.json({ error: 'template_exists' }, { status: 409 });
      templates.set(data.name, { version: 1, published: false });
      return Response.json({ id: 1, name: data.name, version: 1, status: 'draft', vars: [{ name: 'name', kind: 'var', required: true }] }, { status: 201 });
    }
    if (route.endsWith('/publish')) {
      const name = decodeURIComponent(route.split('/').at(-2)!);
      const template = templates.get(name);
      if (!template) return Response.json({ error: 'template_not_found' }, { status: 404 });
      template.published = true;
      return Response.json({ id: 1, name, version: 1, status: 'published' });
    }
    if (data.templateId && !templates.get(data.templateId)?.published) return Response.json({ error: 'template_not_found' }, { status: 422 });
    const key = new Headers(init.headers).get('Idempotency-Key')!.toLowerCase();
    const known = messages.get(key);
    if (known) return known.body === init.body ? Response.json({ id: known.id, status: 'duplicate' }) : Response.json({ error: 'idempotency_conflict' }, { status: 409 });
    const id = `mock-message-${String(messages.size + 1).padStart(4, '0')}`;
    messages.set(key, { body: String(init.body), id });
    return Response.json({ id, status: 'sandbox' }, { status: 202 });
  };
  return { requests, transport };
}
shared/private-store.ts
shared/private-store.ts
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync } from 'node:fs';
import { dirname, resolve } from 'node:path';

export function openPrivateDatabase(path: string) {
  // Keep this restrictive for the lifetime of the process: SQLite can recreate
  // its WAL/SHM files after the opening call has returned.
  process.umask(0o077);
  if (path === ':memory:') return new DatabaseSync(path);
  const absolute = resolve(path);
  const directory = dirname(absolute);
  mkdirSync(directory, { recursive: true, mode: 0o700 });
  const folder = lstatSync(directory);
  if (!folder.isDirectory() || folder.isSymbolicLink() || (folder.mode & 0o077) !== 0) {
    throw new Error('Use a dedicated data directory with mode 0700; shared directory permissions were not changed.');
  }
  for (const file of [absolute, `${absolute}-wal`, `${absolute}-shm`]) {
    if (!existsSync(file)) continue;
    if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()) throw new Error('Database state must be regular files, not symlinks.');
    // Harden existing state in place without recreating or truncating it.
    chmodSync(file, 0o600);
  }
  const descriptor = openSync(absolute, constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
  closeSync(descriptor);
  chmodSync(absolute, 0o600);
  return new DatabaseSync(absolute);
}

Failures to handle

  • event_payload_changed: the same event was reused for different content; investigate instead of generating a new provider key.
  • needs_reconciliation or already_processing: another request claimed the event, or a result remains uncertain. Do not start a competing send.
  • reconciliation_window_expired: this sample refuses automatic resends beyond 23 hours, before the provider 24-hour deduplication window can expire.

Limits & operational decisions

  • SQLite and the sample schema belong to the downloaded backend example; the public website has no database.
  • This is not an exactly-once transport. A crash after provider acceptance and before the local commit requires reconciliation.
  • Operator resume assumes no old worker is still running; a distributed queue, leases, full order-system integration and long-term retention policy are outside this starter.
  • Mock and sandbox use separate default database files. Sandbox still consumes quota and never delivers. No authenticated API call was executed.
  • The database helper keeps process umask 077 so later WAL/SHM creation stays private; it never changes permissions on a shared parent directory.

Copy for your agent

The context package contains this recipe’s complete files, prerequisites, execution effects, tests, limitations and sources. It is task information, with no hidden instructions.

Read context as Markdown

Sources & verification

Maintained by Oveyon, the provider used in these examples. This relationship is commercial and explicit. Code is MIT licensed; no independent endorsement is implied.

Updated 2026-10-28. Local fixtures on Node.js 22.23.2 and contract review; authenticated sandbox and delivery not executed.

Next step

Run the mock tests first. For an authenticated integration, create a key with the permissions stated in the recipe and verify the current contract in the official reference. An API acceptance response is not proof of inbox placement.

Type two or more characters. Try “duplicate”, “webhook” or “retry”.