# Dragonfly Partner API — Agent & Merchant Guide

_Version 2026-10-07.2 · Base URL: `https://api.trydragonfly.com` · OpenAPI 3.1: [`/v1/openapi.json`](https://api.trydragonfly.com/v1/openapi.json) · Reference: [`/v1/reference`](https://api.trydragonfly.com/v1/reference) · [`/llms.txt`](https://api.trydragonfly.com/llms.txt)_

Dragonfly OS lets merchants — and their AI agents (MCP, CLI, or plain REST) — sign up,
get approved, and dispatch real last-mile deliveries.

## Try it NOW — sandbox (no approval needed)

Pass `"sandbox": true` to signup and you get a fully working playground account
**in one request** — API key included in the response. Sandbox orders simulate the
full lifecycle (PENDING → ASSIGNED → PICKED_UP → DELIVERED over ~3 minutes), no real
drivers move, nothing is ever billed.

```bash
curl -s https://api.trydragonfly.com/v1/signup -H 'content-type: application/json' -d '{
  "sandbox": true,
  "businessName": "My Test Kitchen",
  "contactName": "Dev Agent",
  "email": "dev@example.com",
  "vertical": "catering"
}'
# → { data: { sandbox: true, status: "APPROVED", apiKey: "dfk_...", ... } }
```

Then create orders immediately (same API as production — see §3). When you're ready
for real deliveries, sign up again without `sandbox` for a reviewed production account.

**Human-friendly demo:** a live read-through of the merchant portal (sample data +
walkthrough video) is at [merchants.trydragonfly.com/demo](https://merchants.trydragonfly.com/demo).

## Who can send orders

Only **approved merchants** can push orders. There are three ways in:

| You are… | How you get a key |
|---|---|
| Just exploring | `POST /v1/signup` with `"sandbox": true` → a `dfk_test_…` key **instantly**. Simulated deliveries, never billed. |
| A new business | `POST /v1/signup` → a Dragonfly admin reviews it → your first poll returns a `dfk_live_…` key once. |
| **Already a Dragonfly merchant** | Sign in at **merchants.trydragonfly.com → API access → Request API access**. Once an admin approves it, the account owner creates the key on that page (shown once) and hands it to the agent or integration. (`/v1/signup` answers `409 EMAIL_IN_USE` for existing accounts.) |

Keys stop working immediately when revoked or when the merchant account is deactivated (`403 MERCHANT_INACTIVE`).

## How your orders are handled

- **Dispatched on receipt (default):** a single dropoff goes to a catering driver; 2–25 dropoffs go out as one multi-stop route. `dispatchedTo` tells you which.
- **Routed by Dragonfly:** for accounts on routed intake (pharmacy, grocery and meal-kit runs), each order is stored and Dragonfly builds the day's optimized routes. The response says `"dispatchedTo": "dragonfly_routing"`, and `status` stays `PENDING` until your stop's route is assigned. After that it follows **your stop**: `ASSIGNED → PICKED_UP → DELIVERED`. Send one order per recipient, with a delivery window. Cancel freely until your order is placed on a route; after that, contact support.

## Quick start (production happy path)

1. **Apply**: `POST /v1/signup` (no auth). Save `applicationId` + `pollToken`.
2. **Poll**: `GET /v1/signup/{applicationId}` with header `X-Signup-Token: <pollToken>`
   until `status=APPROVED`. The **first** poll after approval returns your
   `apiKey` + `signingSecret` **exactly once** — store them.
3. **Create orders**: `POST /v1/orders` with `Authorization: Bearer <apiKey>`.
4. **Track**: `GET /v1/orders/{externalId}`, or register a callback URL for signed push updates.

## 1. Sign up — `POST /v1/signup`

```bash
curl -s https://api.trydragonfly.com/v1/signup -H 'content-type: application/json' -d '{
  "businessName": "Oak Street Kitchen",
  "contactName": "Dana Smith",
  "email": "dana@oakstreetkitchen.com",
  "phone": "+13125550100",
  "vertical": "catering",
  "pickupAddress": "14 Oak St, Chicago, IL 60601",
  "expectedWeeklyOrders": 40
}'
```

`vertical` ∈ `catering | restaurant | grocery | parcels | industrial_3pl | pharmacy | medical | meal_kits | other`.
Your vertical sets your default pricing (rate card); an account manager can adjust it after onboarding.

Response (201): `{ data: { applicationId, pollToken, status: "PENDING", ... } }`
⚠️ `pollToken` is shown **once**. One live application per email. Rate limit: 10/hour.

## 2. Poll for approval — `GET /v1/signup/{id}`

```bash
curl -s https://api.trydragonfly.com/v1/signup/APPLICATION_ID -H 'X-Signup-Token: POLL_TOKEN'
```

- `PENDING` → keep polling (a human approves; typically within 1 business day — poll every few minutes, not seconds).
- `REJECTED` → `reviewNotes` says why.
- `APPROVED` → the **first** poll returns `{ apiKey, signingSecret, merchantId, subMerchantId }` once.
  Later polls return `apiKeyClaimed: true` (ask an admin to rotate if lost).

## 3. Create an order — `POST /v1/orders`

Auth: `Authorization: Bearer <apiKey>` (or HMAC mode, below). Idempotent on `externalId` —
retry safely; a replay returns the existing order, never a duplicate.

```bash
curl -s https://api.trydragonfly.com/v1/orders \
  -H "Authorization: Bearer $DRAGONFLY_API_KEY" -H 'content-type: application/json' -d '{
  "externalId": "order-1001",
  "pickup":  { "address": "14 Oak St, Chicago, IL 60601", "contactName": "Kitchen", "contactPhone": "+13125550100" },
  "dropoff": { "address": "445 Michigan Ave, Chicago, IL 60611", "contactName": "Reception", "contactPhone": "+13125550111", "windowStart": "2026-07-03T17:00:00Z", "windowEnd": "2026-07-03T17:30:00Z" },
  "subtotalCents": 42500,
  "tipCents": 5000
}'
```

- Single dropoff → `dropoff`. Multi-stop route (2–25 stops) → `dropoffs: [...]`. Routing is automatic.
- Every stop **requires** a reachable `contactPhone`. Give dropoffs a `contactEmail` and the recipient gets the proof-of-delivery email.
- `items: [{ name, quantity, sku? }]` puts a checklist in front of the driver; add `checklistRequired: true` to require every item be ticked off.
- Multi-location accounts: pass `subMerchantId` for the pickup location. `metadata` is free-form and echoed in webhooks.
- Always send windows as ISO 8601 with an offset (`2026-10-09T10:30:00-07:00`).
- Money is integer **cents**. `subtotalCents` drives tiered pricing — send it.
- Response: `{ data: { orderId, status, trackingUrl, dispatchedTo, idempotentReplay } }`
- **Task options** on any stop (Onfleet `requirements` / Nash `requirements` equivalents; enforced in the driver app):
  `requiresSignature`, `photoRequired` (`NONE|OPTIONAL|REQUIRED`), `notesRequired`, `minimumAge` (ID check),
  `pincodeRequired` (4-digit code shown only on the recipient's tracking page), `barcodes: [{ data, blockCompletion }]`,
  `barcodeRequired`, `quantity`.

## 3b. Cancel — `POST /v1/orders/{externalId}/cancel`

Body `{ "reason"?: string }`. Allowed while `PENDING` or `ASSIGNED`; **409 `CANCEL_NOT_ALLOWED`** once picked up.
Idempotent: cancelling an already-cancelled order returns 200 with `alreadyCancelled: true`.

## 4. Track — `GET /v1/orders/{externalId}`

Status lifecycle: `PENDING → ASSIGNED → PICKED_UP → DELIVERED` (`FAILED`/`CANCELLED` terminal).
Response includes `trackingUrl`, `promisedDeliveryAt`, `deliveredAt`, `onTime`.

### Webhooks (recommended) — `/v1/webhooks`
Register your own HTTPS endpoint(s) with your API key; every event for an order this key
created is delivered there, signed and retried (Nash/Svix-compatible — see the full guide
in `docs/WEBHOOKS.md`):

```bash
curl -s https://api.trydragonfly.com/v1/webhooks -H "Authorization: Bearer $DRAGONFLY_API_KEY" -H 'content-type: application/json' \
  -d '{ "url": "https://example.com/dragonfly", "events": ["delivery.*"], "description": "prod" }'
# → { data: { id, url, events, secret: "whsec_…" } }   ← secret shown ONCE
```

- `GET /v1/webhooks/events` — the catalogue (`delivery.created`, `delivery.assigned_driver`, `delivery.pickup_complete`,
  `delivery.dropoff_complete`, `delivery.failed`, `delivery.canceled_by_*`, `delivery.proof_of_delivery`, …) with the
  Nash status / Onfleet trigger each one maps to. `events: ["*"]` or `["delivery.*"]` wildcards work.
- `GET/PATCH/DELETE /v1/webhooks/{id}`, `GET /v1/webhooks/{id}/deliveries` (attempts), `POST /v1/webhooks/{id}/test`.
- Envelope: `{ type: "delivery", event, id: "msg_…", occurredAt, data: { delivery: {…}, nashStatus, onfleetTrigger } }`.
- Headers: `dragonfly-id`, `dragonfly-timestamp`, `dragonfly-signature: v1,<base64 HMAC-SHA256(secret, id.ts.body)>`
  (also mirrored as `svix-*`). Retries: 0 s, 5 s, 5 m, 30 m, 2 h, 5 h, 10 h, 10 h on 5xx / network errors.

### Legacy push callbacks
Ask for a `callbackUrl` on your key (or at signup review) and Dragonfly POSTs signed
events (`order.created`, status transitions) to it. Verify `X-Dragonfly-Signature`:
HMAC-SHA256 of the raw body with your `signingSecret`.

## Auth modes

| Mode | How |
|------|-----|
| Bearer (default) | `Authorization: Bearer <apiKey>` |
| HMAC | `X-Dragonfly-Key: <keyPrefix>` + `X-Dragonfly-Signature: hex(hmacSHA256(rawBody, signingSecret))` |

## CLI

```bash
curl -s https://api.trydragonfly.com/v1/cli -o dragonfly.mjs
export DRAGONFLY_API_KEY=dfk_live_...  # after approval (sandbox keys: dfk_test_...)
node dragonfly.mjs signup signup.json          # apply (writes .dragonfly-signup.json)
node dragonfly.mjs poll                        # poll + auto-claim the API key
node dragonfly.mjs create-order order.json     # create an order
node dragonfly.mjs get-order order-1001        # status
```

## MCP server (Claude Desktop / Claude Code / any MCP client)

```bash
curl -s https://api.trydragonfly.com/v1/mcp -o dragonfly-mcp.mjs
```

```json
{ "mcpServers": { "dragonfly": {
  "command": "node", "args": ["/path/to/dragonfly-mcp.mjs"],
  "env": { "DRAGONFLY_API_KEY": "dfk_live_...", "DRAGONFLY_API_URL": "https://api.trydragonfly.com" }
} } }
```

Tools exposed: `dragonfly_signup`, `dragonfly_signup_status`, `dragonfly_create_order`, `dragonfly_get_order`, `dragonfly_cancel_order`.

## Errors

Every error has the same shape: `{ "error": { "code", "message", "details"? } }`. On `VALIDATION_ERROR`, `details` lists each bad field, e.g. `[{ "path": "dropoffs.1.contactPhone", "message": "A valid contact cellphone number is required" }]`.

| HTTP | code | What to do |
|---|---|---|
| 400 | `VALIDATION_ERROR` | Fix the fields in `details`, then retry with the same `externalId`. |
| 400 | `INVALID_SUB_MERCHANT` | That pickup location isn't on your account. |
| 401 | `UNAUTHORIZED` | Missing, invalid or revoked key. |
| 403 | `MERCHANT_INACTIVE` | The account is deactivated. Contact support. |
| 403 | `FORBIDDEN` | The key lacks the scope (`orders:write`/`orders:read`). |
| 404 | `NOT_FOUND` | No such order on **your** account. |
| 409 | `CANCEL_NOT_ALLOWED` | Already picked up, or already on a Dragonfly route. |
| 429 | `RATE_LIMITED` | Back off and retry. |

Retries are always safe: `POST /v1/orders` is idempotent on `externalId`.

## Limits & rules

- 100 requests/min per API key (429 with `RATE_LIMITED` beyond).
- Signup: 10/hour per IP. Poll: 60/hour.
- 1–25 dropoffs per order. Idempotency window: forever (externalId is permanent).
- Billing: weekly invoice per your vertical's rate card; details in your merchant portal
  (you also receive a portal invite email on approval).

_Questions: support@trydragonfly.com · This page: `GET /v1/docs` (markdown; `?format=html` for a styled page)._
