Documentation

One endpoint. Bearer auth.

Everything below is the whole product. If you can send a JSON POST, you can use Backfire. Replace the host in the examples with your deployment's forward function URL.

Illustration: a request leaving an envelope and returning as a JSON response

Quickstart

  1. 1Create an account and claim the 5,000-trip signup balance.
  2. 2Mint a key in the dashboard. The full value is shown exactly once.
  3. 3POST to the forward endpoint with your key as a bearer token.

Authentication

Keys open with backfire_, carry 36 random characters and close with _api. Send one per request:

header
Authorization: Bearer backfire_4c9aE7Qp…_api

We store a SHA-256 hash and a display prefix — never the key itself. Revoking a key in the dashboard takes effect on the very next request. There is no anonymous access.

Forward a request

POST/functions/forward
curl -sS https://backfireapi.base44.app/functions/forward \
  -H "Authorization: Bearer $BACKFIRE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.stripe.com/v1/charges",
    "method": "POST",
    "headers": { "Idempotency-Key": "ord_8123" },
    "body": { "amount": 4200, "currency": "usd" },
    "timeout_ms": 12000
  }'

Anything that speaks HTTP goes through: any method token, any request content-type — JSON, form posts, XML, protobuf, binary uploads via body_base64 — over http:// or https://, on any port that is not a mail port. Headers pass through in both directions, and every status comes back untouched, including 3xx with its location.

FieldTypeDefaultNotes
urlstringrequiredAbsolute http or https target.
methodstring"GET"Any HTTP method token — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, or a custom verb such as PROPFIND.
headersobject{}Up to 32 string headers, forwarded as given. Hop-by-hop headers are stripped.
bodyanynullObject (sent as JSON) or string (sent as text/plain). Sent for any method except GET and HEAD.
body_base64stringnullBase64 of a binary body — files, protobuf, multipart parts. Sent as application/octet-stream. Takes precedence over body.
timeout_msnumber150001000–30000. Your value is clamped, never exceeded.

The envelope

200 ok
{
  "ok": true,
  "request_id": "fwd_8c4a1e9b17d34f0a",
  "upstream": {
    "status": 200,
    "status_text": "OK",
    "headers": { "content-type": "application/json" }
  },
  "body": { "encoding": "json", "json": { "id": "ch_3Pq…" } },
  "meta": {
    "bytes_in": 96,
    "bytes_out": 1482,
    "trips_charged": 1,
    "duration_ms": 214,
    "target_host": "api.stripe.com",
    "target_method": "POST",
    "redirect_not_followed": false
  },
  "quota": { "remaining": 4999, "unit": "trips" }
}

Body policy, once and for all: if the upstream content-type contains json and the payload parses, you get body.encoding = "json" with the parsed value. If it is valid UTF-8 but not JSON, you get encoding = "text". Anything else comes back as encoding = "base64" with a byte count, and an empty response (204, 304, HEAD) comes back as encoding = "empty". There are no other cases.

Live sockets

A forward ends when the reply lands. For anything that stays open — market feeds, collaborative documents, game state, chat — mint a ws:// or wss:// ticket and the bridge holds the connection for you: frames are relayed in both directions, text or binary, until either side closes. The same target rules apply — no private ranges, no metadata endpoints, no mail ports.

socket bridge
# 1. mint a ticket (the key check, target check and first trip happen here)
POST /functions/openSocket
{ "url": "wss://stream.example.com/feed" }
→ { "ok": true, "socket": { "session_id": "6a1f…" }, "quota": { "remaining": 4988 } }

# 2. attach the bridge to that ticket and it holds the socket open
import { base44 } from '@/api/base44Client';

const room = base44.actors.SocketBridge(ticket).connect();
room.send({ type: 'open', ticket });
room.send({ type: 'frame', data: '{"subscribe":"trades"}', encoding: 'text' });
// inbound: { type: 'frame', direction: 'in', encoding: 'text', data }

A ticket is charged one trip for opening the socket — one roundtrip — then one trip per hundred relayed frames, counted in both directions. Every open and close is written to the activity log with its frame counts, byte counts and duration. The dashboard console and the browser client stamp each frame with a header computed in WebAssembly, so frame length and version are read the same way on both sides.

Webhooks

A forward already answers its own caller, so pushing the same result to a second place would be noise. What a webhook is for is the work nobody is watching: a request that was refused, a target that was blocked, a balance that just ran out, a key that appeared or was revoked, a top-up that landed.

Register an endpoint in the console → Webhooks. You get a signing secret exactly once, the same way a key is shown once. Every post is application/json with an HMAC-SHA256 signature over the timestamp and the raw body, so a receiver can prove the event came from us and reject a replay.

a signed delivery
POST /hooks/backfire
content-type: application/json
x-backfire-event: forward.blocked
x-backfire-delivery: 6f1c2a70b9d34e51
x-backfire-attempt: 1
x-backfire-signature: t=1751492400,v1=6a1f…

{
  "id": "evt_5d21c0a4e7b94918",
  "event": "forward.blocked",
  "created_at": "2026-11-01T18:24:03.112Z",
  "account_id": "acct_8c4a1e9b",
  "request_id": "fwd_8c4a1e9b17d34f0a",
  "data": {
    "error_code": "blocked_target",
    "status": 403,
    "target_host": "169.254.169.254",
    "method": "GET"
  }
}

Answer with a 2xx inside 5 seconds. Anything else — a timeout, a 500, a redirect — counts as a failed attempt. Redirects are never followed. Duplicate deliveries are possible (at-least-once), so dedupe on the envelope id.

verify a delivery
// Node — verify with the exact bytes you received, never a re-serialised body.
import crypto from 'node:crypto';

export function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((piece) => piece.split('='))
  );
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  const given = Buffer.from(parts.v1 || '', 'hex');
  const mine = Buffer.from(expected, 'hex');
  if (given.length !== mine.length) return false;
  if (!crypto.timingSafeEqual(given, mine)) return false;

  // 5 minute replay window
  return Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
}

// 2xx = accepted. Anything else is retried on the schedule below.

Event catalogue

EventFires onPayload fields
forward.blockedBlocked targetPrivate, loopback or metadata address, DNS failure, bad scheme, blocked port, oversized request.error_code, status, target_host, method, request_id
forward.deniedRefused forwardRevoked key, frozen account, rolling-minute ceiling, empty balance.error_code, status, key_prefix, target_host, request_id
quota.lowBalance lowOnce per crossing, on the forward that took the balance to the reserve.balance, trips_charged, threshold
quota.emptyBalance emptyOnce, when a completed forward leaves the balance at zero.balance, trips_charged
key.createdKey mintedEvery successful key creation from the console.key_id, prefix, last4, name
key.revokedKey revokedEvery revocation, including one made in response to a leak.key_id, prefix, last4, name, revoked_at
topup.completedTop-up creditedOnce per captured order, after the server verifies amount and currency.trips, balance, reference, note
webhook.testTest eventOnly when you press Send test.message, endpoint_id

What is never in a payload: request headers, request bodies, response bodies, cookies, and key material. Events carry counts, identifiers, hostnames already visible to you in the console, and status codes. A webhook cannot smuggle out what a trip does not keep.

Delivery and retries

The event is written to a queue first and posted from there, so a slow or broken receiver can never add latency to a forward or fail one. Each queued row is retried after 1m, 5m, 30m, 120m — stopping after 5 attempts, when the row is marked exhausted and left in the console with its reason.

The queue flushes whenever you open the console, when you press Send test, and from the drain function. Point a scheduled task at /functions/webhookDrain once a minute and the queue becomes fully hands-off; that call requires an admin session and only sends rows that are already queued and due, so running it more often is harmless. Up to 6 endpoints per account, each with its own event selection and its own secret.

  • Endpoints must be https; plain http is refused at registration.
  • Destination URLs pass the same private, loopback, link-local and metadata blocking as a forward.
  • Redirects are never followed — a 3xx is a failed attempt.
  • 5 second per-attempt timeout, so a hung receiver cannot hold a worker.
  • Deliveries are never charged in trips and never touch your balance.
  • Pause an endpoint and it queues nothing at all until you resume it.
  • Every attempt — accepted, failed, exhausted — is written to the activity log.
  • Rotating the secret invalidates the old one immediately; the endpoint keeps running.

Endpoint & key

This is the exact endpoint your client calls. Copy it, paste a key minted in the console, and the first forward is one request away.

forward endpoint

https://backfireapi.base44.app/functions/forward

first forward

curl -sS https://backfireapi.base44.app/functions/forward \
  -H "Authorization: Bearer backfire_…_api" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://api.github.com/zen","method":"GET"}'

Errors

Failures return the same shape with ok: false and anerror.code you can branch on.

missing_key401No bearer key was supplied.
invalid_key401Key not found or not a Backfire key.
key_revoked401The key was revoked. Mint a new one.
account_frozen403Account frozen for abuse response.
insufficient_trips402Balance is empty.
rate_limited429Past this key’s 600-a-minute burst. Honour retry_after_seconds (10) before retrying.
relay_busy503The relay is at its per-minute ceiling. Wait for the retry hint — about 10 seconds.
blocked_target403Target is not a permitted public http/https address.
dns_failure403Resolution failed or was ambiguous — we fail closed.
payload_too_large413Request body over the per-call allowance.
upstream_timeout502Upstream did not answer inside the timeout.
response_too_large502Upstream body over the per-call allowance.

Limits and safety

  • http and https targets, any method token, any content-type — including custom verbs.
  • Private, loopback, link-local, multicast and cloud-metadata ranges blocked on IPv4 and IPv6.
  • DNS failures and ambiguous answers fail closed.
  • Redirects are never followed; 3xx is returned to you.
  • Hop-by-hop headers stripped; CR or LF in headers rejected.
  • Per-call size allowances keep any single request bounded.
  • 30 second maximum upstream timeout.
  • 600 forwards per key per rolling minute, 1,200 per account — the whole budget can go in one burst.
  • Every refusal is logged with its full detail; a completed trip keeps its counters only, and its transfer data is purged as it settles.
  • Every interaction — sign-in, sign-out, keys, relays, log reads — travels over HTTPS; a plain-HTTP request is redirected before it is served.

Throughput and the cooldown

Every forward is authorised, checked and metered against a ledger on both legs: the key and the target on the uptrip, the response, the byte counts and the resulting balance on the downtrip. That ledger is what makes revocation take effect on the next request, keeps a blocked address from ever opening a socket, and settles your balance to the exact trip. It runs in its own database, so metering never queues behind the rest of the app — which is why the ceiling below is capacity rather than a shared budget.

Nothing about the transfer itself is kept. We do not trace, log or archive it: the target, the headers, the request body and the response are purged the moment the trip completes, leaving only the counters.

  • 600 forwards per key per rolling minute, 1,200 per account — the full budget can be spent in one burst.
  • No trace log: the target, headers, body and response are purged the moment a trip completes.
  • Past that: HTTP 429, error.code rate_limited, retry_after_seconds: 10.
  • Ten seconds is the cooldown. Retrying inside it only earns another refusal.
  • A refusal opens no connection upstream and costs no trips.
  • The minute keeps draining while you wait; a full 600-call burst returns a minute after it started.
  • A wider per-account ceiling stops one key from starving the keys beside it.
429 too many forwards
HTTP/1.1 429 Too Many Requests

{ "ok": false,
  "request_id": "fwd_5d21c0a4e7b94918",
  "error": { "code": "rate_limited",
             "message": "This key allows 600 forwards per rolling minute. Wait 10 seconds, then retry." },
  "retry_after_seconds": 10 }

# honour it in one line — nothing was sent upstream and nothing was charged
await new Promise((r) => setTimeout(r, (body.retry_after_seconds ?? 10) * 1000));

Three ways to stay under it: run one worker per key with a short pause between calls; batch the upstream work so one forward carries many records instead of one; and send anything long-lived to the socket bridge, where a single open ticket relays frames rather than spending a forward per message. If the platform underneath is momentarily at capacity the answer is relay_busy (503) with the same retry hint — not a 500.

Metering

One trip is one complete roundtrip: 1 trip = 1 roundtrip = 1 uptrip + 1 downtrip — one uptrip carries your request to the target, one downtrip carries its answer back. They are never charged separately, so a completed forward is exactly one trip.

Every forward draws from your balance once and reports the exact figure asmeta.trips_charged andquota.remaining, so you never need a second endpoint to find out where you stand.

Validation failures — bad key, blocked target, rate limit, oversized request — cost nothing. An attempt that reaches the network is charged once, including timeouts and upstream errors, because the work was done. Balances never expire and there is no subscription.

Each charge is itemised in the activity log with the byte counts, the timing and the balance that followed — and only those counters. A completed trip leaves nothing of the transfer behind: its detail is purged as it settles, so no stored target, header or body can surface later. Refusals never reach the network, so they keep their full detail and a blocked target stays diagnosable. The trail is readable in the dashboard and exportable as JSON, CSV or Markdown.

Account endpoints

Balance, keys and logs are managed from the dashboard, which calls the account functions with your signed-in session:

/functions/accountBalance, keys and the activity trail.
/functions/exportActivityJSON, CSV or Markdown export of the full log.
/functions/createApiKeyMints a key. Returns the plaintext once.
/functions/revokeApiKeyRevokes immediately.

Only the forward endpoint accepts a backfire_…_api key. Account routes require a browser session, so a leaked relay key cannot read your balance or mint new keys.