Lewati ke isi

Brand Partner API

A small, predictable REST API for creating mintbot orders, polling their status, and receiving lifecycle events. JSON in, JSON out. Bearer-token auth. Idempotent writes. Signed webhooks.

API access dashboard Jump to webhooks


At a glance

Base URL https://mint.mintbot.ai/api/v1
Auth Authorization: Bearer mo_live_…
Idempotency Idempotency-Key: <uuid> on every POST except POST /dns/records
Content type application/json
Rate limit 120 req / 60 s per partner
Webhooks Signed with HMAC-SHA256, retried up to 7 times

Authentication

Every request sends a partner API key in the Authorization header. Generate or rotate the key in the dashboard.

Authorization: Bearer mo_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

Rotation has no grace window

Rotating the key revokes the previous one atomically. Plan to swap the value in your config before clicking Rotate.

Idempotency

Every POST requires an Idempotency-Key header — except POST /dns/records, which is naturally idempotent at the provider layer and accepts retries without one. Any UUID per distinct request works.

  • We cache the response for 24 hours. A retry with the same key replays the original response with Idempotent-Replay: true.
  • Reusing the same key with a different body returns 409 idempotency_key_mismatch.

Orders

Create order

POST /orders

Creates an order and returns a Stripe Checkout URL. Once payment confirms, mintbot provisions the agent and the partner-cut revenue event is recorded automatically.

Request

{
  "tier": "mo_month",
  "duration_months": 1,
  "credit_usd": 10,
  "language": "en",
  "external_id": "your-side-id",
  "success_url": "https://your.app/thanks?id={ORDER_ID}",
  "cancel_url":  "https://your.app/cart",
  "webhook_url": "https://your.app/mintbot-webhook"
}
tier
One of mo_test (Test 24h), mo_month (Standard), mo_month_plus (Performance).
duration_months
Calendar months of server lifetime. Must be one of 1, 3, or 12. mo_test is always a single 24-hour run, whatever you send.
credit_usd
Optional, default 0. One of 0, 5, 10, 20, 50. Pre-funded chat credit (USD) bundled with the order. It is added to the order total and goes to mintbot — it is not part of your margin.
language
Optional. Affects Stripe Checkout locale and welcome-email language.
external_id
Optional. Echoed back on every webhook and order response — use it to thread mintbot orders to rows in your own system.
success_url · cancel_url
Stripe Checkout return URLs. {ORDER_ID} is substituted server-side.
webhook_url
Optional. Per-order override of the partner-level webhook URL.
product_name
Optional. The line-item label the customer sees on Stripe Checkout (max 250 characters). Defaults to a generated TIER · N months label.
price_usd
Optional. Price override in USD for the whole order. It can't go below your wholesale floor for that tier and duration — otherwise 400 validation_error.

Response — 201 Created

{
  "id": 42,
  "tier": "mo_month",
  "duration_months": 1,
  "credit_usd": 10,
  "amount_cents": 1200,
  "currency": "usd",
  "status": "pending",
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_…",
  "panel_url": null,
  "expires_at": null,
  "language": "en",
  "external_id": "your-side-id",
  "created_at": "2026-05-16 09:58:00",
  "paid_at": null,
  "price_cents": 1200,
  "partner_cut_cents": 200
}

If you're in custom pricing mode and haven't priced this tier, the response also carries "pricing_fallback": true.

Example

curl -X POST https://mint.mintbot.ai/api/v1/orders \
  -H "Authorization: Bearer $MINTBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "tier": "mo_month",
    "duration_months": 1,
    "credit_usd": 10,
    "success_url": "https://your.app/thanks?id={ORDER_ID}",
    "cancel_url":  "https://your.app/cart"
  }'
import os, uuid, requests

r = requests.post(
    "https://mint.mintbot.ai/api/v1/orders",
    headers={
        "Authorization": f"Bearer {os.environ['MINTBOT_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "tier": "mo_month",
        "duration_months": 1,
        "credit_usd": 10,
        "success_url": "https://your.app/thanks?id={ORDER_ID}",
        "cancel_url":  "https://your.app/cart",
    },
    timeout=10,
)
r.raise_for_status()
order = r.json()
redirect_to = order["checkout_url"]

Get order

GET /orders/{id}

Fetches a single order by its mintbot id. Returns the same shape as POST /orders.

curl https://mint.mintbot.ai/api/v1/orders/42 \
  -H "Authorization: Bearer $MINTBOT_API_KEY"

List orders

GET /orders

Cursor-paginated list, newest first.

Query parameters

status
Optional. Filter by pending, completed, or expired. A paid order stays completed throughout its deploy — follow deploy progress through the agent.* webhooks.
cursor
Optional. Pass next_cursor from the previous page to continue.
limit
Optional. Page size, default 50, max 200.

Response

{
  "items": [ /* OrderResponse … */ ],
  "next_cursor": "37"
}

next_cursor is null when there are no more pages.


Renew order

POST /orders/{id}/renew

Extends an existing agent for another duration_months. Infra-only — no fresh chat credit is bundled. Returns a new order id and a fresh Stripe Checkout URL.

Request

{
  "duration_months": 1,
  "external_id": "your-side-id",
  "success_url": "https://your.app/thanks?id={ORDER_ID}",
  "cancel_url":  "https://your.app/account"
}

Subscriptions

Create subscription

POST /subscriptions

Opens a Stripe Checkout in subscription mode: the customer pays monthly for a monthly package and the subscription renews automatically. Like POST /orders, it needs an Idempotency-Key and a live partner account. The monthly price comes from your pricing for that tier.

Request

{
  "tier": "mo_month",
  "language": "en",
  "success_url": "https://your.app/thanks",
  "cancel_url":  "https://your.app/cart",
  "customer_email": "customer@example.com",
  "external_id": "your-side-id"
}

customer_email, agent_id (an existing agent of yours to bind the subscription to), external_id, and product_name are optional.

Response — 201 Created carries id, tier, currency, amount_cents, partner_cut_cents, status, checkout_url, current_period_end, cancel_at_period_end, agent_id, language, customer_email, external_id, and created_at.

Get subscription

GET /subscriptions/{id}

Returns the same shape as POST /subscriptions.


Revenue

Read revenue

GET /revenue

Totals plus the latest 200 ledger events.

Query parameters

include_paid
Optional, default true. Set to false to see only events that haven't been paid out yet.

Response

{
  "currency": "usd",
  "gross_cents": 12000,
  "partner_cut_cents": 2000,
  "mintbot_cut_cents": 10000,
  "unpaid_cents": 800,
  "events": [
    {
      "id": 7,
      "order_id": 42,
      "kind": "order_paid",
      "gross_cents": 1200,
      "partner_cut_cents": 200,
      "mintbot_cut_cents": 1000,
      "currency": "usd",
      "created_at": "2026-05-16 09:58:00",
      "payout_id": null,
      "payout_at": null
    }
  ]
}

Partner profile

Get profile

GET /partner

Echoes your partner profile plus the unpaid balance — handy for surfacing earnings inside your own admin UI without storing them yourself.

Response

{
  "id": 12,
  "email": "you@kliendifirma.com",
  "pricing_currency": "usd",
  "balance_unpaid_cents": 800,
  "webhook_url": "https://your.app/mintbot-webhook",
  "api_key_prefix": "mo_live_a12b"
}

Settings

Get settings

GET /settings

Returns your full non-secret partner configuration in a single payload — chosen modes, apex domain, DNS provider, bot provider, customization repo, subdomain pattern (with a rendered preview label), per-tier pricing, API key metadata, webhook metadata, and the same readiness / per-section status that drives the MintOffice dashboard banner.

Designed for integration agents that need to discover how the partner is set up without scraping the dashboard or interrogating the operator. A typical caller is a coding agent installing your white-label setup on a fresh server — it can read this endpoint, branch on readiness.status, and tell the operator exactly which sections still need attention.

No secret is ever returned

Anything the partner supplied as a credential — Telegram bot token, Zone.ee API key, webhook signing secret, the API key plaintext itself — surfaces only as a *_set boolean. The webhook secret additionally exposes the same 8-char display prefix that's already shown in the dashboard, so a caller can confirm which secret is configured without ever seeing the value.

Response

{
  "id": 12,
  "email": "you@kliendifirma.com",
  "status": "live",
  "created_at": "2026-05-12 14:22:01",
  "onboarded_at": "2026-05-12 14:48:33",
  "activated_at": "2026-05-13 09:10:05",
  "last_login_at": "2026-05-20 00:51:18",
  "preferred_language": "en",
  "pricing_currency": "usd",
  "balance_unpaid_cents": 800,

  "domain": {
    "mode": "own",
    "client_apex_domain": "agent99.cc",
    "dns_provider": "zone_ee",
    "zone_ee_username": "you@kliendifirma.com",
    "zone_ee_api_key_set": true,
    "subdomain_prefix": "agent",
    "subdomain_numbering": "text",
    "subdomain_pad_width": 3,
    "subdomain_label_preview": "agent1"
  },

  "bot": {
    "mode": "own",
    "provider": "telegram",
    "telegram_bot_username": "agent99cc_bot",
    "telegram_bot_token_set": true
  },

  "template": {
    "mode": "default",
    "repo_url": null
  },

  "api": {
    "key_prefix": "mo_live_a12b",
    "key_created_at": "2026-05-13 09:11:42",
    "webhook_url": "https://your.app/mintbot-webhook",
    "webhook_secret_set": true,
    "webhook_secret_prefix": "ab12cd34"
  },

  "pricing": {
    "mode": "default",
    "currency": "usd",
    "tiers": {
      "mo_test":       { "price_cents": 100,  "partner_cut_cents": 0,   "updated_at": "2026-05-13 09:05:00" },
      "mo_month":      { "price_cents": 1200, "partner_cut_cents": 200, "updated_at": "2026-05-13 09:05:00" },
      "mo_month_plus": { "price_cents": 1800, "partner_cut_cents": 300, "updated_at": "2026-05-13 09:05:00" }
    },
    "allowed_credit_options": [10, 20, 50]
  },

  "readiness": {
    "ready": true,
    "missing_fields": [],
    "dns_blocker": null,
    "status": "live"
  },

  "section_status": {
    "domain":   { "status": "ok",      "mode": "own",     "issue": null, "dns_blocker": null },
    "bot":      { "status": "ok",      "mode": "own",     "issue": null, "dns_blocker": null },
    "template": { "status": "default", "mode": "default", "issue": null, "dns_blocker": null },
    "api":      { "status": "ok",      "mode": "active",  "issue": null, "dns_blocker": null },
    "all_ready": true
  }
}

Field reference

status
Partner lifecycle. One of onboarding, live, suspended. The Brand Partner API rejects writes unless the status is live; only the mintbot team moves you to live.
domain.mode
own (you bring your own apex domain) or mintbot (mintbot provides a *.mintbot.ai subdomain). When mintbot, the DNS-provider fields are intentionally empty regardless of what the partner previously entered.
domain.subdomain_label_preview
The rendered label the first customer agent would receive (agent1, 1, 001, …) given the current subdomain_prefix, subdomain_numbering, and subdomain_pad_width. Use it for a "this is what your customers will see" preview without re-implementing the numbering rules.
bot.mode
own (partner-provided Telegram bot), mintbot (using mintbot's bot during testing), or none (panel only, no Telegram).
template.mode
default (no agent customization repo — the agent runs on the plain unbranded base) or own (the partner's agent customization repo at repo_url). When own, the agent's own VPS clones that repo after the standard deploy and runs its install.sh / update.sh to apply your persona, panel theme, and any extra tooling on top of the base — central never executes the repo itself. Point repo_url at your GitHub HTTPS agent customization repo when you want custom branding.
api.webhook_secret_prefix
First 8 characters of the webhook signing secret, only present when a secret is configured. Lets a caller distinguish which secret is in place without seeing the full value. The dashboard shows the same prefix.
pricing.allowed_credit_options
The credit_usd bundles you offer your customers (chosen in Settings → Pricing).
readiness.missing_fields
List of dashboard field names still required before Go Live. Empty when ready is true. Stable enough to drive a "what's still missing" UI in your own admin.
readiness.dns_blocker
A human-readable reason when the DNS provider check fails (e.g. partner has no zone.ee username configured). null otherwise.
section_status.*
Mirrors the dashboard's per-section status pills. status is ok, warn, or default (a mintbot default is in use); issue names the field blocking that section (e.g. client_apex_domain, telegram_bot_token, dns_blocker), or null.

Example

curl https://mint.mintbot.ai/api/v1/settings \
  -H "Authorization: Bearer $MINTBOT_API_KEY"
import os, requests

r = requests.get(
    "https://mint.mintbot.ai/api/v1/settings",
    headers={"Authorization": f"Bearer {os.environ['MINTBOT_API_KEY']}"},
    timeout=10,
)
r.raise_for_status()
settings = r.json()

if not settings["readiness"]["ready"]:
    print("Still missing:", settings["readiness"]["missing_fields"])

Catalog

The sellable package list for your storefront — the Brand Partner packages (mo_test / mo_month / mo_month_plus) joined with your resolved pricing, in your pricing currency. Render your plan cards from this instead of hard-coding tier slugs and prices: retired packages disappear on their own, and a price or currency change you make in the dashboard reaches your storefront within its cache window — no redeploy.

This is the endpoint that keeps a storefront from advertising yesterday's packages. The reference portal calls it on every landing / /buy / /extend render (cached, with a sticky fallback).

Display copy stays yours

display_name / description / featured are the canonical mintbot package metadata. If you want to rename a package or rewrite its blurb for your brand, override it in your own storefront (the reference portal ships a PLAN_OVERRIDES map for exactly this) — the catalog is the source of truth for which packages exist and what they cost, not for your marketing copy.

Get catalog

GET /api/v1/catalog

No parameters. Bearer auth only — there is no activation gate, so a storefront can render the catalog before your partner account is flipped live.

Response

{
  "currency": "usd",
  "packages": [
    {
      "tier": "mo_test",
      "display_name": "Test 24h",
      "description": "2 vCPU · 4 GB RAM · 40 GB SSD · 24-hour evaluation. EU.",
      "featured": false,
      "default_credit_usd": 5,
      "durations": [
        { "months": 1, "label": "24 hours", "price_cents": 0 }
      ],
      "subscription": { "available": false, "price_cents": null }
    },
    {
      "tier": "mo_month",
      "display_name": "Standard",
      "description": "2 vCPU · 4 GB RAM · 40 GB SSD, monthly. EU.",
      "featured": false,
      "default_credit_usd": 10,
      "durations": [
        { "months": 1,  "label": "1 month",   "price_cents": 1500 },
        { "months": 3,  "label": "3 months",  "price_cents": 4500 },
        { "months": 12, "label": "12 months", "price_cents": 18000 }
      ],
      "subscription": { "available": true, "price_cents": 1500 }
    }
  ],
  "credit_options": [
    { "usd": 5,  "price_cents": 500 },
    { "usd": 10, "price_cents": 1000 }
  ]
}
Field Meaning
currency Your pricing currency. Every price_cents below is denominated in it.
packages[] One entry per public package, in offer/display order.
tier The slug you pass to POST /orders / POST /subscriptions.
display_name / description / featured Canonical display copy (override in your storefront if you want).
default_credit_usd The package's bundled LLM credit hint (informational).
durations[] The setup durations this package can be bought for. months is the value you send as duration_months; price_cents is what the order will bill. Monthly packages advertise 1 / 3 / 12 months; mo_test advertises a single 24-hour duration.
subscription available is true for packages that can be bought as a monthly auto-renew via POST /subscriptions; price_cents is the monthly amount. mo_test is never subscription-capable.
credit_options[] Each credit_usd bundle priced in your currency (usd = credit amount, price_cents = what it adds to the order total). Which bundles you offer is pricing.allowed_credit_options in GET /settings.

Prices match what you'll be billed

Each price_cents is computed with the same pricing resolver the order endpoint uses, so a card price always equals the eventual Stripe Checkout total for that (tier, duration_months).

Example

curl -s https://mint.mintbot.ai/api/v1/catalog \
  -H "Authorization: Bearer $MINTBOT_API_KEY" | jq
import os, requests

r = requests.get(
    "https://mint.mintbot.ai/api/v1/catalog",
    headers={"Authorization": f"Bearer {os.environ['MINTBOT_API_KEY']}"},
    timeout=10,
)
r.raise_for_status()
catalog = r.json()

for pkg in catalog["packages"]:
    month1 = next(d for d in pkg["durations"] if d["months"] == 1)
    print(pkg["display_name"], month1["price_cents"], catalog["currency"])

DNS records

Brand-partner integrations often need to provision DNS records on the partner's apex domain — pointing @, www, or per-customer subdomains at the right server. MintOffice exposes a vendor-blind proxy so you can do this without ever handling the upstream provider's API key in your own infrastructure. The credentials you saved during onboarding (today: zone.ee; cloudflare and route53 are roadmapped behind the same shape) stay encrypted in MintOffice; your call comes in with Bearer mo_live_… and we make the upstream call on your behalf.

Scope is the partner's own configured apex (domain.client_apex_domain from GET /settings). You can not touch a record on a zone you don't own.

If your DNS provider is Manual, the proxy changes nothing: writes are no-ops and lists come back empty.

Today the proxy supports the operations the deploy pipeline itself uses — A-records (upsert / list / delete by id). CNAME / TXT / MX support will follow the same shape when the underlying provider abstraction grows them.

Idempotency-Key not required here

Unlike order-creation endpoints, DNS upserts are naturally idempotent at the provider layer (re-running with the same (subdomain, ip) is a no-op and stale duplicates are swept on every call). You do not need to send the Idempotency-Key header — retries always converge on a single A-record per FQDN.

Upsert an A-record

POST /dns/records

Idempotently point <subdomain>.<your-apex> at an IPv4 address. Pass "" (or "@") for the apex itself.

Request

{
  "subdomain": "www",
  "ip": "203.0.113.10"
}

subdomain accepts:

  • "" or "@" — the apex itself (example.com).
  • A single DNS label — "www", "api" — for <label>.<apex>.
  • Dotted sub-labels — "api.eu" — for nested subdomains (api.eu.example.com).

ip must be a dotted IPv4 address. Each label is [a-z0-9_-], 1–63 chars, no leading/trailing -.

Response — 200

{
  "apex": "example.com",
  "provider": "zone_ee",
  "fqdn": "www.example.com",
  "records": [
    { "id": "9182734", "name": "www.example.com", "destination": "203.0.113.10" }
  ]
}

The post-write records list always reflects the upstream provider's current view of the FQDN after a duplicate sweep — so a successful response with exactly one record is the happy-path signal that nothing stale remains.

List records

GET /dns/records

Returns every A-record on the configured apex. Add ?name=<label-or-fqdn> to filter to one FQDN — the value may be a bare label ("www"), a dotted subdomain ("api.eu"), or the full FQDN ("www.example.com").

Response — 200

{
  "apex": "example.com",
  "provider": "zone_ee",
  "items": [
    { "id": "9182734", "name": "example.com",     "destination": "203.0.113.10" },
    { "id": "9182735", "name": "www.example.com", "destination": "203.0.113.10" }
  ]
}

Delete a record

DELETE /dns/records/{record_id}

record_id is the vendor identifier returned by GET /dns/records (zone.ee issues integer ids, stringified at the API boundary). Deleting a record that no longer exists is treated as a success — the endpoint is idempotent on the client side.

Response — 200

{ "deleted": true, "id": "9182734" }

Errors

HTTP error.code Cause
401 unauthenticated / invalid_api_key Missing / bad Bearer token.
422 validation_error Bad subdomain or non-IPv4 destination.
422 dns_not_configured Partner has no client_apex_domain or the configured provider's credentials are missing. Fix in Settings → Domain.
422 dns_provider_unsupported The partner's DNS provider is not yet wired into the MintOffice proxy (e.g. cloudflare placeholder). Switch to zone_ee for now.
502 dns_provider_error Upstream provider returned a 4xx/5xx, or the transport failed. The error message carries the upstream status and a short, secret-redacted excerpt of the upstream body. Safe to retry.

Example

# Point apex + www at your server's IP.
curl https://mint.mintbot.ai/api/v1/dns/records \
  -H "Authorization: Bearer $MINTBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subdomain": "",    "ip": "203.0.113.10"}'

curl https://mint.mintbot.ai/api/v1/dns/records \
  -H "Authorization: Bearer $MINTBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subdomain": "www", "ip": "203.0.113.10"}'

# Verify.
curl "https://mint.mintbot.ai/api/v1/dns/records" \
  -H "Authorization: Bearer $MINTBOT_API_KEY"
import os, requests

URL = "https://mint.mintbot.ai/api/v1/dns/records"
H = {"Authorization": f"Bearer {os.environ['MINTBOT_API_KEY']}"}

for sub in ("", "www"):
    r = requests.post(URL, headers=H, json={"subdomain": sub, "ip": "203.0.113.10"})
    r.raise_for_status()
    print(sub or "@", r.json()["records"])

Webhooks

When something happens to an order, we POST a signed JSON event to your configured webhook URL.

Webhooks are optional

The Webhook URL and Webhook signing secret in Settings → API access are entirely optional — leave them blank during onboarding if you don't have a receiver yet. Deliveries only go out once both are set — with a URL but no signing secret, no events are sent. The dashboard and the rest of the API work fine without webhooks; you can poll GET /api/v1/orders/{id} to track lifecycle instead. Add the URL (and generate the secret) whenever you're ready, no re-activation needed.

Event types

Event When it fires
order.created Stripe Checkout session minted, awaiting payment.
order.paid Stripe confirmed payment. Revenue event recorded.
order.cancelled Order timed out or was explicitly cancelled.
agent.provisioning_started Deploy pipeline started for this order.
agent.ready Deploy succeeded. Payload contains panel_url and expires_at.
agent.failed Deploy pipeline errored. error_step names the step that broke; error_message is a short summary.
agent.expired Agent TTL elapsed. The partner may renew via POST /orders/{id}/renew.
agent.resumed A previously-expired agent was renewed within its grace window and is live again. Recovery counterpart to agent.expired.
subscription.created · subscription.activated · subscription.updated · subscription.cancelled Lifecycle of a POST /subscriptions subscription.
partner.activated · partner.deactivated The mintbot team activated your account or took it off live (suspended in the payload tells you whether it was suspended or rewound to onboarding).

Delivery & retries

  • Up to 7 attempts on an exponential schedule: 0s, 30s, 2m, 10m, 1h, 6h, 24h.
  • After the last attempt, the delivery is marked exhausted and stops retrying.
  • Respond with a 2xx status within 10 seconds to acknowledge.

Request headers

Content-Type: application/json
User-Agent: mintbot-webhook/1.0
X-Mintbot-Signature: t=<unix_ts>,v1=<hex_hmac_sha256>
X-Mintbot-Event-Id: evt_42_order.paid_1747371234567
X-Mintbot-Event-Type: order.paid

Sample payload — order.paid

{
  "id": 42,
  "tier": "mo_month",
  "duration_months": 1,
  "currency": "usd",
  "amount_cents": 1200,
  "status": "completed",
  "external_id": "your-side-id",
  "created_at": "2026-05-16 09:58:00",
  "paid_at": "2026-05-16 10:02:14",
  "expires_at": null
}

Verifying the signature

The signature is HMAC-SHA256(secret, "{timestamp}.{raw_body}"). Always verify the raw request body — re-serialising your parsed JSON will break the comparison.

import hmac, hashlib, time

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        ts_part, v1_part = header.split(",", 1)
        ts  = int(ts_part.split("=", 1)[1])
        sig = v1_part.split("=", 1)[1]
    except Exception:
        return False
    if abs(int(time.time()) - ts) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{ts}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, sig)
const crypto = require("crypto");

function verify(secret, rawBody, header, toleranceSec = 300) {
  const [tsPart, v1Part] = header.split(",");
  const ts  = Number(tsPart.split("=")[1]);
  const sig = v1Part.split("=")[1];
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.`)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(sig, "hex"),
  );
}

Idempotent receivers

Use X-Mintbot-Event-Id as your dedup key — retries reuse the same id, so a row-level INSERT … ON CONFLICT DO NOTHING on that column keeps your handler safe.

Reference portal

A runnable end-to-end reference lives at mintbot-ai/partner-portal-example — FastAPI + SQLite, Dockerised, ships with an example brand called ExampleAI you fork and swap out. The architectural picture — how the storefront, this API, the deploy worker, and the customization repo fit together — is on MintOffice — Technical. The portal implements:

  • the landing / plan-picker / thank-you / cancel pages,
  • POST /buy → POST /api/v1/orders → Stripe redirect,
  • POST /webhooks/mintoffice with the HMAC verifier referenced above,
  • an HTTP-Basic-auth /admin event browser so you can eyeball deliveries.

Fork it, set PARTNER_BRAND, MINTOFFICE_API_KEY, and MINTOFFICE_WEBHOOK_SECRET in .env, run docker compose up, and point your partner row's webhook URL at https://<your-host>/webhooks/mintoffice. See MintOffice → Reference portal for the full walkthrough.


Errors

Every error response carries a stable code you can branch on programmatically. The message field is a human-readable hint and may change between releases. The request_id echoes the X-Request-Id response header — include it in support tickets.

{
  "error": {
    "code": "invalid_api_key",
    "message": "API key is unknown or revoked.",
    "request_id": "f0c2d6c4-…"
  }
}
Code Meaning
unauthenticated Missing or malformed Authorization header.
invalid_api_key API key is unknown or has been rotated out.
rate_limited Per-partner or per-IP rate limit exceeded — see Retry-After.
missing_idempotency_key POST request without Idempotency-Key.
idempotency_key_mismatch Same key reused with a different request body.
validation_error Request body failed schema validation.
not_found Resource does not exist or does not belong to your partner.
partner_not_live HTTP 403 — a write (POST /orders, /orders/{id}/renew, /subscriptions) while your account isn't live. The body carries status, and for onboarding accounts missing_fields + dns_blocker.
conflict HTTP 409 — a request with the same Idempotency-Key is still being processed. Retry after Retry-After.
unpriced_tier HTTP 409 — the tier has no price to bill.
internal_error Unexpected server-side error. Include the request_id when contacting support.
payment_gateway_error Stripe rejected the Checkout session creation.
dns_not_configured POST /dns/records against a partner with no apex / no DNS credentials. Fix in Settings → Domain.
dns_provider_unsupported The partner's dns_provider value is not wired into the MintOffice proxy yet.
dns_provider_error Upstream DNS provider returned an error — message carries a redacted excerpt.
quota_exceeded HTTP 429, DEV environment (mint.mintbot.dev) only — LIVE has no per-partner cap. Partner has consumed every deploy slot (10 by default, 20 after the Brand Partner setup fee). Contact mintbot support to raise the cap. Returned by POST /orders and POST /orders/{id}/renew. The body carries deploys_used + deploy_quota.

Rate limits

  • 120 requests / 60 s rolling window, per partner. Burst and steady-state share the same bucket.
  • 60 requests / 60 s separate per-IP bucket for unauthenticated and failed-auth requests — keeps a bearer brute-force from chewing through the partner quota.
  • Excess requests return 429 rate_limited with a Retry-After header (seconds to back off).

Need help?

These docs are written for the partners who actually use the API. If something is missing, confusing, or stale, mention it to your mintbot agent — they'll forward the feedback and we'll update the page.