Essentras API for developers
Search 3,800+ media outlets, quote a basket at your own prices, buy placements from your prepaid wallet, submit briefs and track live links, all from code. REST and MCP, one key, hard spend limits.
Overview
The Essentras API lets you do everything the website does for a buyer, programmatically: search the catalogue of 3,800+ vetted outlets, quote a basket, buy paid placements from your prepaid wallet, submit the article brief, and read back the live links once they are published.
Base URL: https://essentras.com/api/agent/v1. All requests and responses are JSON over HTTPS. Money is in USD.
| Mode | What you can do | Needs a key |
|---|---|---|
| Anonymous, read-only | Search outlets, read an outlet, quote a basket at public prices, read the product feed, run the free case checkers. | No |
| Authenticated | Everything above at your own prices, plus your account, wallet, and orders: place orders paid from your wallet, submit briefs, track live links. | Yes, a bearer key |
The anonymous endpoints (/feed and the free /check case checkers) are described in the agents guide. An anonymous quote returns a checkout_url that a person opens to pay by card. The authenticated API never takes card data: orders are paid from the wallet.
Machine-readable descriptions: OpenAPI 3.1, llms.txt, and the MCP server at https://essentras.com/api/mcp.
Getting started
- Create an account. Sign up at /signup. Reseller accounts are set up by our team (see Pricing below).
- Add funds. Go to /account/wallet and top up by card (minimum $50). Resellers can also pay by wire, which our team records against your wallet.
- Create an API key. Go to /account/api, pick the permissions and optional limits, and copy the key. It is shown once and cannot be recovered.
- Make your first call.
GET /mereturns your account, wallet balance and the key's own limits.
export ESSENTRAS_API_KEY="paste-your-key-here" # set it in your shell or secret manager, never in code
curl -s https://essentras.com/api/agent/v1/me -H "Authorization: Bearer $ESSENTRAS_API_KEY"Authentication
Send your key as a bearer token. X-Api-Key is also accepted for clients that cannot set the Authorization header.
Authorization: Bearer ess_live_a1b2c3d4e5f6_<43 character secret>
# or, for clients that cannot set Authorization:
X-Api-Key: ess_live_a1b2c3d4e5f6_<43 character secret>- Key format:
ess_live_, a 12 character hex id, an underscore, then a 43 character secret (256 bits of randomness). - Only a SHA-256 hash of the key is stored. We cannot show it to you again. If you lose it, revoke it and create a new one.
- Never put a key in browser or front-end code, a mobile app bundle, a public repository or a shared config file. Call the API from a server you control.
- Session cookies are never accepted by the API. Only keys are.
- Revocation is immediate. Every request re-checks the key, nothing is cached.
- We email you when a key is created and when one is revoked, so an unexpected key is noticed quickly.
- A bad key is always an error (401). The API never falls back to public prices or ignores a write because the key was wrong.
Scopes
| Scope | Allows |
|---|---|
catalog:read | Read the catalogue and quote baskets at your own prices. Optional on the public endpoints, required if you send a key to them. |
wallet:read | GET /wallet, and the balance inside GET /me. |
orders:read | GET /orders and GET /orders/{id}. |
orders:write | POST /orders and PUT /orders/{id}/brief. Implies orders:read: if you pick it, orders:read is added automatically. |
GET /me works with any valid key. Create keys with the fewest scopes the integration needs: a reporting job needs orders:read, not orders:write.
Guard rails per key
- Per-order limit: the largest single order total the key may place.
- Rolling 24 hour limit: the most the key may order over any 24 hour window. Enforced under a lock, so parallel requests cannot exceed it together.
- IP allowlist: up to 20 addresses. Requests from anywhere else get 403 ip_not_allowed.
- Expiry: 1 to 3,650 days. An expired key answers 401 api_key_expired.
- You can hold up to 10 active keys per account.
A key can only ever spend the wallet balance of the account that created it.
Pricing
With a key, search, outlet detail, quote and orders all use your own prices. The same function prices the quote and the order, so the total you quote is the amount the order takes from your wallet.
Reseller accounts
- A reseller has a price book set by our team: fixed per-outlet prices, or a percentage off list price, or both (a fixed price wins for that outlet).
- Percentage prices are floored so nothing is sold below our cost plus a minimum margin. Outlets with a fixed price are not floored.
- A reseller price is never above list price.
- Percentage prices on outlets with no known provider cost are not floored.
- Reseller prices are net: the public volume tiers do not stack on top, unless your account is set to allow it (GET /me shows volume_tiers_apply).
- You never pay more than the public basket price. We compute both your basket and the public basket (list price plus the public volume tiers) and charge the lower total. The quote shows both as total and public_total, and pricing_basis tells you which one applied. If the public basket is cheaper, the whole quote and order are at list price plus the public tiers (pricing_basis is list).
Public accounts
Accounts without a reseller price book pay list price plus the public volume tiers: 3 or more placements take 10% off the basket, 5 or more take 15%. This is identical to the website.
Search filters and caching
- Search filters such as
min_priceandmax_priceapply to the list price, not your reseller price. Read your price fromprice.amountand the list price fromprice.list_amount. - Successful keyed responses carry
Cache-Control: private, no-storeand keyed errors carryno-store. Do not cache them in a shared cache. - Each outlet can be bought once per order. There is no quantity.
Endpoints reference
All paths are relative to https://essentras.com/api/agent/v1. Errors share one shape, documented under Errors. The anonymous endpoints GET /feed and /check also live here; see the agents guide for them.
/meThe account behind the key: whether it buys on a reseller price book, the wallet balance (when the key has wallet:read), and this key's own permissions and limits.
{
"object": "account",
"email": "[email protected]",
"name": "Dana Lee",
"reseller": {
"active": true,
"company": "Acme Media",
"wholesale_outlets": 612,
"default_discount_percent": 20,
"custom_prices": 14,
"volume_tiers_apply": false
},
"wallet": {
"available": 1840.5,
"reserved": 0,
"currency": "USD",
"top_up_url": "https://essentras.com/account/wallet"
},
"api_key": {
"name": "Production server",
"key": "ess_live_a1b2c3d4e5f6_••••••••",
"scopes": ["catalog:read", "wallet:read", "orders:read", "orders:write"],
"max_order_usd": 2000,
"daily_limit_usd": 5000,
"ip_restricted": true,
"created_at": "2026-10-18T09:12:44.000Z",
"expires_at": null
},
"purchases_enabled": true
}Errors: 401 (authentication_required, invalid_api_key, api_key_revoked, api_key_expired), 403 ip_not_allowed, 429 rate_limited. wallet is null without wallet:read. purchases_enabled is false while ordering is paused.
/walletWallet balance and the most recent ledger rows. Amounts are signed: positive is money in, negative is money out.
Query: limit (1 to 100, default 25).
{
"object": "wallet",
"currency": "USD",
"available": 1840.5,
"reserved": 0,
"purchased_balance": 1840.5,
"promotional_balance": 0,
"refundable": 1840.5,
"top_up_url": "https://essentras.com/account/wallet",
"transactions": [
{
"id": "cm2x0k3ab0001",
"created_at": "2026-10-18T09:30:02.000Z",
"kind": "spend",
"amount": -440,
"status": "settled",
"source": null,
"order_id": "cm2x0k3zq0042",
"note": "Credit applied to paid order"
},
{
"id": "cm2wz91xc0007",
"created_at": "2026-10-18T14:05:11.000Z",
"kind": "topup",
"amount": 2000,
"status": "settled",
"source": "stripe",
"order_id": null,
"note": null
}
]
}Errors: 401, 403 insufficient_scope (with required_scope), 429.
/outletsSearch buyable outlets. Works without a key at public prices. With a key, each result carries your own price and priced_for is account.
| Param | Type | Notes |
|---|---|---|
q | string | Match on publication name. |
country | string | Country name or ISO code, for example US. |
region | string | North America, Europe, Middle East, Latin America, Asia Pacific, Africa, Oceania, Global. |
language | string | BCP-47 code, for example en. |
topic | string | Topic or genre, for example fintech. |
min_price, max_price | number | USD, applied to list price. Values like $500 and 1,000 are accepted. |
min_da, min_dr | number | Minimum Domain Authority or Domain Rating. |
dofollow, indexed | boolean | true or false. |
sort | string | popularity (default), price_asc, price_desc, authority_desc, traffic_desc. |
limit, offset | integer | Page size (max 100) and offset. |
curl -s "https://essentras.com/api/agent/v1/outlets?max_price=500&country=US&dofollow=true&limit=2" \
-H "Authorization: Bearer $ESSENTRAS_API_KEY"
{
"total": 214,
"limit": 2,
"offset": 0,
"has_more": true,
"results": [ /* offers, shaped as above */ ],
"priced_for": "account"
}Errors: 400 invalid_filter (a parameter could not be read, listed in invalid), 401, 403, 429, 500 search_failed.
/outlets/{id}One outlet by its stable id (the slug returned by search). With a key the price block is yours.
{
"id": "example-daily",
"object": "offer",
"type": "media_placement",
"name": "Media placement on Example Daily",
"outlet": {
"name": "Example Daily",
"website": "https://example-daily.example",
"country": "US",
"region": "North America",
"languages": ["en"],
"topics": ["business", "technology"],
"domain_authority": 72,
"domain_rating": 68,
"monthly_traffic": 1200000
},
"price": {
"amount": 240,
"list_amount": 300,
"basis": "reseller_discount",
"currency": "USD",
"unit": "per_placement",
"volume_discount": "Reseller pricing. The quote and order endpoints always charge the lower of your price book and the public basket price."
},
"delivery": {
"turnaround": "3-5 business days",
"billing_trigger": "on_order"
},
"link": { "dofollow": true, "indexed": "YES", "sponsored_tag": "NO", "disclosure": "Published as a standard editorial article, with no sponsored label." },
"availability": "in_stock",
"url": "https://essentras.com/publishers/example-daily",
"buy_url": null,
"order_endpoint": "https://essentras.com/api/agent/v1/orders",
"priced_for": "account"
}For a key on a reseller price book, price.basis is reseller_fixed (a price set for your account), wholesale (the wholesale price list) or reseller_discount (your % off list), or list when no reseller price applies, and buy_url is null because the website cart charges public prices. Buy through POST /orders. Errors: 404 not_found, 401, 403, 429.
/quotePrice a basket. Creates nothing and charges nothing. With a key it returns the exact total POST /orders would take from your wallet.
Body: { "items": [{ "id": "outlet-id" }] }. Up to 30 outlets: with a key, the first 30 are priced and the rest are listed in truncated with a warning. Duplicates are ignored with a warning. quantity greater than 1 is refused with 400 quantity_unsupported.
curl -s -X POST https://essentras.com/api/agent/v1/quote \
-H "Authorization: Bearer $ESSENTRAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"items":[{"id":"example-daily"},{"id":"sample-business-review"},{"id":"demo-tech-wire"}]}'{
"object": "quote",
"currency": "USD",
"pricing_basis": "reseller",
"line_items": [
{ "id": "example-daily", "name": "Example Daily", "quantity": 1, "unit_price": 240, "list_price": 300, "amount": 240, "price_basis": "reseller_discount" },
{ "id": "sample-business-review", "name": "Sample Business Review", "quantity": 1, "unit_price": 120, "list_price": 150, "amount": 120, "price_basis": "reseller_fixed" },
{ "id": "demo-tech-wire", "name": "Demo Tech Wire", "quantity": 1, "unit_price": 80, "list_price": 100, "amount": 80, "price_basis": "wholesale" }
],
"subtotal": 440,
"discount": { "amount": 0, "percent": 0, "reason": null },
"total": 440,
"public_total": 495,
"unavailable": [],
"truncated": [],
"warnings": [],
"priced_for": "account",
"fees": 0,
"billing_trigger": "on_order",
"order": {
"endpoint": "https://essentras.com/api/agent/v1/orders",
"method": "POST",
"body": { "items": [{ "id": "example-daily" }, { "id": "sample-business-review" }, { "id": "demo-tech-wire" }], "expected_total_usd": 440 },
"note": "Paid from your wallet. Send an Idempotency-Key header and pass expected_total_usd so the order can never be charged a different total than this quote."
}
}Without a key the response is the public quote and includes a checkout_url for a person to pay by card. With a key the order block shows the body to send to POST /orders. Ids that are not buyable are listed in unavailable and left out of the total. Errors: 400 invalid_json, invalid_request, quantity_unsupported; 401; 403; 429; 500 quote_failed.
/ordersPlace an order paid from your wallet. All or nothing: the order, the wallet debit and the brief are written in one transaction.
Headers: Idempotency-Key (required, 8 to 255 printable characters, use a UUID), Content-Type: application/json.
| Body field | Type | Notes |
|---|---|---|
items | array, required | 1 to 30 entries of { "id": "outlet-id" }. Each outlet once. Bare id strings are also accepted. |
expected_total_usd | number, recommended | The total from your quote. If the server total differs by a cent or more, nothing is bought (409 price_changed). |
client_reference | string, optional | Your own reference (job number, end client). Up to 120 characters. Returned on every read of the order. |
brief | object, optional | The article brief, see Briefs. Submit it now or later with PUT /orders/{id}/brief. |
curl -s -X POST https://essentras.com/api/agent/v1/orders \
-H "Authorization: Bearer $ESSENTRAS_API_KEY" \
-H "Idempotency-Key: 7b0f4a52-3a43-4d0e-9c2e-5d3f1c8f6a10" \
-H "Content-Type: application/json" \
-d '{
"items": [{ "id": "example-daily" }, { "id": "sample-business-review" }, { "id": "demo-tech-wire" }],
"expected_total_usd": 440,
"client_reference": "acme-launch-q4",
"brief": {
"subject_name": "Jordan Rivers",
"subject_title": "Founder and CEO of Rivers Labs",
"links": "https://riverslabs.example https://linkedin.com/in/jordanrivers-example",
"goal": "Rivers Labs builds scheduling software for clinics. We want founder-led coverage that positions Jordan as an authority on clinic operations ahead of our Series A.",
"placement_notes": [{ "id": "example-daily", "note": "Lead with the Series A angle." }]
}
}'HTTP/1.1 201 Created
Cache-Control: private, no-store, max-age=0
{
"object": "order",
"id": "cm2x0k3zq0042",
"number": 10482,
"status": "processing",
"payment_status": "paid",
"fulfillment": "in_progress",
"channel": "api",
"created_at": "2026-10-18T09:30:02.000Z",
"paid_at": "2026-10-18T09:30:03.000Z",
"currency": "USD",
"pricing_basis": "reseller",
"subtotal": 440,
"discount": 0,
"discount_reason": null,
"total": 440,
"paid_from_wallet": 440,
"client_reference": "acme-launch-q4",
"line_items": [
{
"id": "example-daily",
"name": "Example Daily",
"type": "publication",
"quantity": 1,
"unit_price": 240,
"list_price": 300,
"amount": 240,
"placement_note": "Lead with the Series A angle.",
"status": "in_progress",
"live_url": null,
"published_at": null
}
],
"brief": { "status": "submitted", "submitted_at": "2026-10-18T09:30:02.000Z", "article_status": "not_started" },
"dashboard_url": "https://essentras.com/orders/cm2x0k3zq0042",
"wallet": { "available": 1400.5, "currency": "USD" },
"next_step": "Brief received. The desk starts writing; poll GET /api/agent/v1/orders/{id} for live links."
}A replay of an earlier successful request returns the stored response with the header Idempotent-Replayed: true. The wallet.available in a replay is the balance at the time of the original order.
Errors
- 400:
invalid_json,idempotency_key_required,invalid_request,too_many_items,duplicate_item,quantity_unsupported,invalid_brief - 401: key errors; 403:
insufficient_scope,ip_not_allowed,order_limit_exceeded,daily_limit_exceeded - 402:
insufficient_funds(withavailable,shortfall,top_up_url) - 409:
price_changed(with the freshquote),idempotency_in_progress - 422:
items_unavailable(withunavailable),idempotency_key_reused,invalid_total - 429:
rate_limited; 500:payment_failed(wallet not charged),internal_error(withorder_may_exist: retry with the same key); 503:purchases_disabled
HTTP/1.1 402 Payment Required
{
"object": "error",
"code": "insufficient_funds",
"message": "Your wallet balance does not cover this order. Nothing was ordered.",
"total": 440,
"available": 120.5,
"shortfall": 319.5,
"top_up_url": "https://essentras.com/account/wallet"
}HTTP/1.1 409 Conflict
{
"object": "error",
"code": "price_changed",
"message": "The total is now 452.00 USD, not 440.00. Nothing was ordered. Re-quote and confirm the new total.",
"quote": { "object": "quote", "total": 452, "...": "full quote, same shape as POST /quote" }
}HTTP/1.1 422 Unprocessable Entity
{
"object": "error",
"code": "items_unavailable",
"message": "1 outlet(s) are not buyable right now, so nothing was ordered. Remove them and retry.",
"unavailable": ["demo-tech-wire"]
}/ordersYour orders, newest first.
Query: limit (1 to 100, default 20), offset, channel (all default, or api for orders placed through the API and the reseller portal).
{
"object": "list",
"total": 37,
"limit": 20,
"offset": 0,
"has_more": true,
"results": [ /* order objects, same shape as GET /orders/{id}, newest first */ ]
}Errors: 401, 403 insufficient_scope, 429.
/orders/{id}One of your orders: payment, fulfilment, brief status, and per-outlet publication with the live article URL once published.
The response is the order object shown under POST /orders (without the wallet and next_step fields). An id that belongs to someone else answers 404, exactly like an id that does not exist. Errors: 404 order_not_found, 401, 403, 429.
/orders/{id}/briefSubmit or replace the brief on one of your paid orders so the team can start writing. POST is accepted as an alias.
Body: the brief fields directly, or wrapped as { "brief": { ... } }. See Briefs for the rules. The response is the updated order object.
curl -s -X PUT https://essentras.com/api/agent/v1/orders/cm2x0k3zq0042/brief \
-H "Authorization: Bearer $ESSENTRAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject_name": "Jordan Rivers",
"subject_title": "Founder and CEO of Rivers Labs",
"links": "https://riverslabs.example",
"goal": "Rivers Labs builds scheduling software for clinics. We want founder-led coverage that positions Jordan as an authority on clinic operations.",
"avoid": "Do not mention competitors by name.",
"title_ideas": "Why clinics lose 20% of their day to scheduling",
"notes": "Tone: practical, no hype.",
"placement_notes": [{ "id": "example-daily", "note": "Lead with the Series A angle." }]
}'Errors: 400 invalid_json, invalid_brief (with field); 404 order_not_found; 409 order_not_paid, brief_locked; 401; 403; 429.
Placing an order safely
The recommended flow is two calls: quote, then order with the quoted total and an idempotency key.
POST /quotewith the outlets you want. Readtotaland checkunavailableis empty.- Generate a fresh UUID for this intended order and store it before you send anything.
POST /orderswithIdempotency-Key: <uuid>andexpected_total_usdset to the quoted total.- If the request times out or the connection drops, retry with the same key and the same body.
Idempotency semantics
- Same key and same body: the stored response is replayed with the header
Idempotent-Replayed: true. Nothing is bought a second time. - Same key and a different body: 422
idempotency_key_reused. Use a new key for every new order. - Same key and same body while the first request is still running (at most 15 minutes): 409
idempotency_in_progress. Wait a few seconds and retry with the same key. - A request refused before an order is paid creates no order and releases its key: after fixing the cause (topping up the wallet, or re-quoting after
price_changedand sending the newexpected_total_usd) you can retry with the same key. - If anything fails after the order was paid, the key is kept and the response is 500
internal_errorwithorder_may_exist: true. Retry with the SAME key to get the order back, never with a new key. - Keys are kept for 24 hours and are scoped to your account.
All or nothing, and the wallet
- If any outlet is unavailable, nothing is bought: 422
items_unavailablelists every one, so you can remove them and retry. - The wallet is debited atomically with the order. Two concurrent orders can never spend the same dollars.
- If the balance is too low: 402
insufficient_fundswithavailable,shortfallandtop_up_url. Nothing is ordered. - If you pass
expected_total_usdand the price moved: 409price_changedwith the fresh quote. Nothing is ordered. - Send the brief inline with the order, or submit it later. The desk cannot start writing until a brief is submitted.
Briefs
The brief tells the writer who the article is about. It goes through the same quality check as the website brief form, because a thin brief produces a weak article.
| Field | Required | Rules |
|---|---|---|
subject_name | Yes | Who or what the article is about. At least 2 characters, up to 200. |
links | Yes | Website, social profiles and existing press we research from. Must contain at least one real link. Up to 4,000 characters. |
goal | Yes | The business and what the coverage should achieve. At least 50 characters, up to 8,000. |
subject_title | No | Their title or a one-line description. Up to 200 characters. |
avoid | No | Anything off-limits. Up to 4,000 characters. |
title_ideas | No | Headline ideas. Up to 4,000 characters. |
notes | No | Anything else the writer should know. Up to 8,000 characters. |
placement_notes | No | Array of { "id", "note" }, up to 30, to give a per-outlet angle. Each id must be an outlet on the order. Notes up to 2,000 characters. |
A brief that fails validation is refused with 400 invalid_brief and a field that names the problem. Submitting a brief again replaces the previous one.
The brief is locked once a draft is out for your approval or any placement is live: 409 brief_locked. After that, request changes from the order page on the website (dashboard_url in the order object). A brief can only be submitted on a paid order (409 order_not_paid otherwise).
// Orders placed without a brief sit in "awaiting_brief" until you send one.
await api('/orders/' + order.id + '/brief', {
method: 'PUT',
body: JSON.stringify({
subject_name: 'Jordan Rivers',
links: 'https://riverslabs.example',
goal: 'Rivers Labs builds scheduling software for clinics. We want founder-led coverage that positions Jordan as an authority on clinic operations.',
}),
})Order lifecycle
Read payment_status and fulfillment on the order object. Orders paid from the wallet are marked paid when they are created.
| fulfillment | Meaning |
|---|---|
awaiting_payment | The order is not paid yet. API orders are paid at creation, so you will rarely see this. |
awaiting_brief | Paid, but no brief has been submitted. Send one with PUT /orders/{id}/brief. |
in_progress | Brief received. The team is writing and placing. |
partially_published | At least one placement is live and at least one is not. |
published | Every placement is live. Each line_items[].live_url is set. |
cancelled | The order was cancelled. |
refunded | The order was refunded. |
Each entry in line_items has a status (in_progress or published), a live_url and a published_at timestamp once it is live. brief.article_status tracks the draft.
Refunds follow our refund policy, which refunds to the original payment method. For an order paid from your wallet that is your wallet: the part you paid with your own funds comes back as refundable balance, and any promotional credit you used comes back as credit. Refunds are issued by our team on request or when a turnaround is missed (we reply within 2 business days and process within 5). Turnaround is counted in business days, pauses while we wait on you, and carries a 3 business day grace period. A partial refund (for example one outlet that could not publish) leaves the order open; a full refund sets payment_status and fulfillment to refunded.
Errors
Every error uses one body shape. Some errors add fields, listed in the table.
{
"object": "error",
"code": "insufficient_scope",
"message": "This API key does not have the orders:write permission. Create a key with it at https://essentras.com/account/api.",
"required_scope": "orders:write"
}| HTTP | code | When | Extra fields |
|---|---|---|---|
| 400 | invalid_json | Body is not valid JSON. | |
| 400 | invalid_request | Missing or malformed field (items, expected_total_usd, client_reference). | field |
| 400 | invalid_filter | A search parameter could not be read. | invalid |
| 400 | invalid_input | A free case checker could not read its input. | details, schema_url |
| 400 | idempotency_key_required | POST /orders without a valid Idempotency-Key. | |
| 400 | too_many_items | More than 30 outlets on POST /orders. | field |
| 400 | duplicate_item | An outlet is listed twice. | field |
| 400 | quantity_unsupported | quantity other than 1. | field (orders) |
| 400 | invalid_brief | Brief failed validation. | field |
| 401 | authentication_required | No key sent to an endpoint that needs one. | |
| 401 | invalid_api_key | Key malformed, unknown or wrong secret. | |
| 401 | api_key_revoked | Key was revoked (also if revoked while an order was in flight). | |
| 401 | api_key_expired | Key passed its expiry. | |
| 402 | insufficient_funds | Wallet does not cover the order. Nothing ordered. | total, available, shortfall, top_up_url |
| 403 | insufficient_scope | Key lacks the needed scope. | required_scope |
| 403 | ip_not_allowed | Request IP is not on the key allowlist. | |
| 403 | order_limit_exceeded | Order total above the key per-order limit. | limit, total |
| 403 | daily_limit_exceeded | Order would exceed the key 24 hour limit. | limit, spent_last_24h, total |
| 403 | forbidden | The product feed requires a token. | |
| 404 | not_found | No buyable outlet with that id. | |
| 404 | order_not_found | No order with that id on this account. | |
| 409 | price_changed | expected_total_usd no longer matches. Nothing ordered. | quote |
| 409 | idempotency_in_progress | Same key is still being processed. Retry with the same key. | |
| 409 | order_not_paid | Brief submitted on an unpaid order. | |
| 409 | brief_locked | Draft is out for approval or a placement is live. | |
| 413 | payload_too_large | Body over the endpoint limit (64 KB for quote and brief, 128 KB for orders, 256 KB default). | |
| 422 | items_unavailable | One or more outlets are not buyable. Nothing ordered. | unavailable |
| 422 | idempotency_key_reused | Same key, different body. | |
| 422 | invalid_total | The basket has no payable total. | |
| 429 | rate_limited | Rate limit hit (per key, per IP, or too many failed key attempts). | |
| 500 | payment_failed | The order could not complete and the wallet was not charged. | |
| 500 | internal_error | Unexpected error. If order_may_exist is true the order was paid: retry with the SAME Idempotency-Key to get it back. Otherwise nothing was ordered. | order_may_exist |
| 500 | search_failed | Search failed. Check your filter values. | |
| 500 | quote_failed | Quote failed. | |
| 503 | purchases_disabled | Ordering is paused. Nothing was charged. Reads still work. | |
| 503 | service_disabled | The agent API is temporarily unavailable. |
Handling guide: 4xx errors are yours to fix and will not succeed on a blind retry (except 409 idempotency_in_progress and 429). 5xx errors on POST /orders are safe to retry with the same Idempotency-Key: if the order was already created you get it back, and if not, no order exists.
Rate limits
- Per key: 600 requests per minute, and 30 order or brief writes per minute (REST and MCP share these budgets).
- Per IP on public endpoints: 120 requests per minute on search, 60 per minute on quote, 120 per minute on the checkers, 10 per minute on the feed.
- The authenticated endpoints also have a per IP ceiling: 120 per minute on /me and /wallet, 240 per minute on order reads, and 60 per minute on order creation and briefs.
- The MCP server allows 120 requests per minute per IP. Batches are capped at 20 messages and 5 tool calls.
- Key attempts are capped at 300 per minute per IP, and repeated failed attempts from one IP are throttled for a minute.
- Exceeding any limit returns 429 with code
rate_limited. Back off and retry shortly.
Need a dedicated quota? Write to [email protected].
MCP server
The MCP server at https://essentras.com/api/mcp speaks Streamable HTTP. Anonymous clients see only the read-only tools (search_outlets, get_outlet, quote_placements and the free checkers). Connect with the Authorization header and, if the key has catalog:read, the same server also returns your own prices (without it you get public prices, where REST returns 403). The account tools are added either way:
| Tool | Scope | What it does |
|---|---|---|
get_account | any valid key | Account, reseller status, wallet balance (with wallet:read), and this key's scopes and limits. |
create_order | orders:write | Buys the listed placements and pays from the wallet. |
list_orders | orders:read | Your orders, newest first. |
get_order | orders:read | One order with live links. |
submit_brief | orders:write | Submit or replace the brief on a paid order. |
tools/list shows only the tools your key's scopes allow. create_order is stricter than the REST endpoint because a model is driving it: it requires expected_total_usd (from quote_placements) and idempotency_key (a unique string of 8 to 255 characters, such as a UUID). A JSON-RPC batch may contain at most one create_order. An invalid key is a 401, never a silent anonymous session. Tool failures come back as MCP results with isError set, not HTTP statuses, and create_order results carry a replayed flag. get_account returns a shorter account object than GET /me.
{
"mcpServers": {
"essentras": {
"type": "http",
"url": "https://essentras.com/api/mcp",
"headers": {
"Authorization": "Bearer ess_live_..."
}
}
}
}Keep the key out of shared configs: do not commit this file, and prefer your client's secret or environment-variable substitution where it has one. Give the key only the scopes the agent needs, and set a per-order limit and a 24 hour limit so a mistaken tool call has a ceiling.
Code examples
Quote, order with idempotency, then poll the order until it is published. The key is read from ESSENTRAS_API_KEY in every example.
curl
# 1. Quote at your own prices
curl -s -X POST https://essentras.com/api/agent/v1/quote \
-H "Authorization: Bearer $ESSENTRAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"items":[{"id":"example-daily"},{"id":"sample-business-review"}]}'
# note the "total" in the response, say 360
# 2. Order it. One fresh UUID per intended order.
KEY=$(uuidgen)
curl -s -X POST https://essentras.com/api/agent/v1/orders \
-H "Authorization: Bearer $ESSENTRAS_API_KEY" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"items":[{"id":"example-daily"},{"id":"sample-business-review"}],"expected_total_usd":360}'
# 3. Poll the order (use the "id" from step 2)
curl -s https://essentras.com/api/agent/v1/orders/ORDER_ID -H "Authorization: Bearer $ESSENTRAS_API_KEY"JavaScript (Node 18+)
// Node 18+ (built-in fetch and crypto)
import { randomUUID } from 'node:crypto'
const BASE = 'https://essentras.com/api/agent/v1'
const headers = {
Authorization: 'Bearer ' + process.env.ESSENTRAS_API_KEY,
'Content-Type': 'application/json',
}
async function api(path, init = {}) {
const res = await fetch(BASE + path, { ...init, headers: { ...headers, ...(init.headers || {}) } })
const body = await res.json()
if (!res.ok) throw Object.assign(new Error(body.code + ': ' + body.message), { status: res.status, body })
return { body, replayed: res.headers.get('Idempotent-Replayed') === 'true' }
}
const items = [{ id: 'example-daily' }, { id: 'sample-business-review' }]
// 1. Quote at your own prices
const { body: quote } = await api('/quote', { method: 'POST', body: JSON.stringify({ items }) })
if (quote.unavailable.length) throw new Error('Not buyable: ' + quote.unavailable.join(', '))
// 2. Order with the quoted total and an Idempotency-Key. Persist the key BEFORE sending,
// so a crash and restart retries the same order instead of starting a new one.
const idempotencyKey = randomUUID()
const { body: order, replayed } = await api('/orders', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ items, expected_total_usd: quote.total, client_reference: 'job-1042' }),
})
console.log(order.id, order.fulfillment, replayed ? '(replayed)' : '')
// 3. Poll until every placement is live (be gentle: every 5 to 15 minutes is plenty)
async function waitForPublication(id) {
for (;;) {
const { body } = await api('/orders/' + id)
console.log(body.fulfillment, body.line_items.map(l => l.live_url))
if (['published', 'cancelled', 'refunded'].includes(body.fulfillment)) return body
await new Promise(r => setTimeout(r, 10 * 60 * 1000))
}
}
await waitForPublication(order.id)Python (requests)
import os
import time
import uuid
import requests
BASE = "https://essentras.com/api/agent/v1"
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + os.environ["ESSENTRAS_API_KEY"]})
def call(method, path, **kwargs):
res = session.request(method, BASE + path, timeout=30, **kwargs)
body = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {body.get('code')}: {body.get('message')}")
return body, res.headers.get("Idempotent-Replayed") == "true"
items = [{"id": "example-daily"}, {"id": "sample-business-review"}]
# 1. Quote at your own prices
quote, _ = call("POST", "/quote", json={"items": items})
if quote["unavailable"]:
raise RuntimeError("Not buyable: " + ", ".join(quote["unavailable"]))
# 2. Order with the quoted total and an Idempotency-Key. Store the key before sending.
idempotency_key = str(uuid.uuid4())
order, replayed = call(
"POST",
"/orders",
headers={"Idempotency-Key": idempotency_key},
json={"items": items, "expected_total_usd": quote["total"], "client_reference": "job-1042"},
)
print(order["id"], order["fulfillment"], "(replayed)" if replayed else "")
# 3. Poll until every placement is live
while True:
current, _ = call("GET", "/orders/" + order["id"])
print(current["fulfillment"], [l["live_url"] for l in current["line_items"]])
if current["fulfillment"] in ("published", "cancelled", "refunded"):
break
time.sleep(600)Security checklist
- Keep keys on servers you control. Never ship one in a browser, a mobile app, a public repository or a chat message.
- Load keys from environment variables or a secret manager. Never log a key, and strip Authorization headers from request logs.
- Create one key per integration so you can revoke one without breaking the others.
- Give each key the fewest scopes it needs. A reporting job should not hold orders:write.
- Set a per-order limit and a rolling 24 hour limit on every key that can place orders.
- Use the IP allowlist when your integration has fixed egress addresses.
- Set an expiry and rotate keys on a schedule. Create the new key, deploy it, then revoke the old one.
- Always send expected_total_usd and an Idempotency-Key. Verify the total in the response matches what you expected.
- Keep the wallet balance close to what you plan to spend, so the balance itself is a ceiling.
- Revoke a key at once at /account/api if you suspect exposure, and watch for the key created and revoked emails.
Changelog
- 2026-10-18: authenticated purchasing API, reseller price books, MCP account tools.