DealSeal API Reference

Three ways to create a negotiation programmatically, plus the one route that writes a sealed value after the fact.

RouteAuthRate limitSealed fields
POST /api/v1/negotiationsPer-key bearer token (ds_...)50/hour per keyAllowed (JSON body)
POST /api/webhook/negotiationsShared webhook key100/hour, all callersAllowed (JSON body)
GET /api/webhook/negotiationsShared webhook key100/hour, all callersRejected with 400
POST /api/negotiations/bottom-lineHost access tokenGlobal 60/min onlySets one, write-only

"Sealed fields" means bottomLine, batna, roiThoughts, and pitch.


Provisioning an API key from the UI
  1. Open /admin and sign in with the admin password.
  2. Go to the API Keys tab.
  3. Enter a name that says who is holding it, and click generate.
  4. Copy the key.

The key authenticates /api/v1/* as Authorization: Bearer ds_.... Revoke from the same list; revocation takes effect immediately.

Two things to know before you hand one to somebody outside ARM:

  • A key is not scoped to its own deals. GET /api/v1/negotiations returns every negotiation in the system - titles, status, both parties' names and emails, asking prices. Sealed values are excluded, but the deal list is not. A second key holder can read your book.
  • Keys are stored in plain text and the admin list returns them in full, so "shown only once" is not literally true today.

Both are known and tracked separately from this document. Until they are fixed, treat API keys as ARM-internal.

The shared webhook key

Separate from the ds_... keys, and used by both /api/webhook/negotiations handlers. Set or rotate it at /admin -> Settings -> Webhook API Key. There is exactly one, so rotating it revokes every caller and every previously issued URL at the same time.

Accepted three ways, in this order:

  1. X-Webhook-Key: <key>
  2. Authorization: Bearer <key>
  3. ?key=<key> in the query string

POST /api/v1/negotiations

Create a negotiation with a per-key bearer token.

Required: title, description, hostName, hostEmail, guestEmail, askingPrice.

Required-adjacent: hostSide - SELLER (default) or BUYER. When BUYER, send initialOffer instead of askingPrice. This is easy to miss and changes which opening number is required.

Optional: bottomLine, additionalTerms, batna, roiThoughts, productUrl, tier (FREE|STANDARD|PREMIUM), maxRounds, sendInvite.

curl -X POST https://dealseal.io/api/v1/negotiations \
  -H "Authorization: Bearer ds_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Used Excavator",
    "description": "2018 Cat 320, 6400 hours, good condition.",
    "hostName": "Alan Ross Machinery",
    "hostEmail": "rr@alanross.biz",
    "guestEmail": "buyer@example.com",
    "hostSide": "SELLER",
    "askingPrice": 85000,
    "bottomLine": 72000
  }'

Rate limit: 50/hour per key.


POST /api/webhook/negotiations

Create a negotiation with the shared key and a JSON body. Party 1 defaults to rr@alanross.biz / Alan Ross Machinery; Party 2's email is required.

Every deal created here is FREE tier with paymentStatus: NONE - unlike the v1 route, tier is not caller-controlled.

curl -X POST https://dealseal.io/api/webhook/negotiations \
  -H "X-Webhook-Key: your_webhook_key" \
  -H "Content-Type: application/json" \
  -d '{
    "party2Email": "buyer@example.com",
    "title": "Used Excavator",
    "description": "2018 Cat 320, 6400 hours, good condition.",
    "hostSide": "SELLER",
    "askingPrice": 85000,
    "bottomLine": 72000
  }'

Returns 201:

{
  "id": "9f3c...",
  "status": "PENDING_GUEST",
  "hostSide": "SELLER",
  "party1Email": "rr@alanross.biz",
  "party2Email": "buyer@example.com",
  "inviteUrl": "https://dealseal.io/invite/...",
  "dashboardUrl": "https://dealseal.io/negotiate/9f3c.../host?token=...",
  "createdAt": "2026-08-08T15:00:00.000Z"
}

dashboardUrl carries the host access token. Do not log it, and do not put it anywhere the counterparty can reach.

Rate limit: 100/hour, shared with the GET handler.


GET /api/webhook/negotiations

The same creation path, driven entirely by a URL, for systems that can only issue a GET. Build one at /admin -> Settings -> Webhook URL Builder.

https://dealseal.io/api/webhook/negotiations?key=YOUR_KEY&party2Email=buyer%40example.com&title=Used%20Excavator&description=2018%20Cat%20320%2C%206400%20hours%2C%20good%20condition.&hostSide=SELLER&askingPrice=85000
curl -sS -G https://dealseal.io/api/webhook/negotiations \
  --data-urlencode "key=your_webhook_key" \
  --data-urlencode "party2Email=buyer@example.com" \
  --data-urlencode "title=Used Excavator" \
  --data-urlencode "description=2018 Cat 320, 6400 hours, good condition." \
  --data-urlencode "hostSide=SELLER" \
  --data-urlencode "askingPrice=85000"

Returns 201:

{
  "id": "9f3c...",
  "status": "PENDING_GUEST",
  "hostSide": "SELLER",
  "party2Email": "buyer@example.com",
  "createdAt": "2026-08-08T15:00:00.000Z"
}

Full parameter table: `docs/OPERATIONS.md`.

Differences from the POST handler

Sealed fields return 400 and write nothing.

{
  "error": "Sealed fields cannot be supplied in a URL",
  "fields": ["bottomLine"],
  "sealedFields": ["bottomLine", "batna", "roiThoughts", "pitch"]
}

A query string is recorded in reverse-proxy logs, browser history, and Referer headers. Set the bottom line afterwards instead - see below.

No `dashboardUrl` and no `inviteUrl` in the response, and Cache-Control: no-store. Anything that fetches a URL speculatively receives the response body, and that body must carry no token at all: dashboardUrl holds the host access token, inviteUrl holds the invite token that lets its bearer submit the guest side. Both are delivered by email, to the people they belong to. POST returns both, because a JSON request body is not cached, logged, or prefetched.

Idempotent for 10 minutes. The webhook key plus the parameters, sorted into a canonical order, are hashed into a fingerprint; a repeat inside the window returns the original deal instead of creating a second one, and sends no second invite email.

{ "id": "9f3c...", "duplicate": true }

Two identical requests arriving together: one creates, the other gets 409 with Retry-After: 5.

Status codes
CodeMeaning
201Created.
200Duplicate inside the window; body carries the existing id.
400Validation failure, or a sealed field in the query string.
401Missing or wrong webhook key.
409An identical request is mid-flight. Retry.
429100/hour exceeded.
503Content moderation unavailable. Retryable; nothing was created.

POST /api/negotiations/bottom-line

Sets Party 1's sealed number after the deal exists. This is what makes a URL-created deal enforceable, since the URL cannot carry the number.

curl -X POST https://dealseal.io/api/negotiations/bottom-line \
  -H "Content-Type: application/json" \
  -d '{"token": "<host access token>", "bottomLine": 72000}'
  • `204` on success, with no body. The value is never echoed back.
  • `409` on any refusal, with one fixed message. Deal already under way, bad value, wrong state - the caller cannot tell which, by design.
  • `404` if the token does not belong to a host.

The negotiation is resolved from the access token, via that token's own host relation. No negotiation id is accepted from the caller, so there is no ownership check that can be reordered into uselessness. A guest token has no host relation and gets the 404.

The write only lands while the deal is PENDING_GUEST. The status guard and the write share one transaction and the guard is a conditional update, so a guest submitting at the same moment either commits first (the write is refused) or waits (the floor lands before the run starts). It cannot interleave into a run that already read null.

Known window: if the guest submits before the host has set a floor, the negotiation starts without one and the engine has nothing to enforce. The runner does not currently hold for this.

Host access tokens

Party.accessToken is a Prisma @default(uuid()) - a v4 UUID, 36 characters, 122 bits of randomness. It is not the 256-bit value from generateToken() in src/lib/tokens.ts, which this field does not use. It is a bearer credential with no expiry: whoever holds it can read the deal and set the bottom line while the deal is pending.