The complete REST surface. Every endpoint below is implemented; nothing here is aspirational.

Ringspine — External API Reference

For tenants integrating against the REST surface. Documents only what's actually implemented; nothing here is aspirational.

Base URL

Every path below is relative to https://ringspine.com, e.g. https://ringspine.com/api/v1/calls.

Authentication

Every request must carry:

Authorization: Bearer rs_live_<...>

Keys are minted per-organization and issued to you exactly once, at creation time — Ringspine never stores or can show you the plaintext again. Only a SHA-256 hash of the key is persisted; if you lose it, mint a new one.

A request with a missing, malformed, unrecognized, or revoked key gets:

401 Unauthorized
{ "error": "Unauthorized" }

There is no way to distinguish "missing header" from "wrong key" from "revoked key" from the response — all three collapse to the same 401, by design.

Money and duration values are JSON strings, not numbers

This governs every route below that returns money or a billed duration — stated once here rather than repeated per route:

Every money amount is an exact decimal JSON string, never a JSON number. That is GET /api/v1/usage's amounts/billableMinutes, GET /api/v1/rates's pricePerMinute, and GET /api/v1/balance's balance/reserved. Several exact decimals cannot be represented by a JSON number without rounding — 0.1 + 0.2 is 0.30000000000000004 in IEEE-754 floating point — so parse these with a decimal library, not parseFloat, if you intend to do arithmetic on them.

billableSeconds (GET /api/v1/calls, GET /api/v1/calls/latest) is the one exception, on purpose. It is a duration, not money, so it is a plain JSON number — and it can be fractional (e.g. 239.6), since a duration cap or a vendor-reported figure is not always a whole second.

POST /api/v1/calls

Places a call. The response body below is deliberately minimal — see "Response — 201 Created" below for the complete, explicit shape it returns.

Request body

Field Type Required Default
personaContext object, { name: string, ...anything } yes —
counterpartContext object, { phoneNumber: string, language?: "en" | "lt", ...anything } yes —
objective non-empty string yes —
situationalContext object (see below) no server-computed
phoneNumberId string no whichever of Ringspine's configured outbound numbers is CHEAPEST for this destination, by real per-minute cost
disclosure string no a built-in, per-language AI-caller/recording disclosure sentence

personaContext and counterpartContext both accept arbitrary additional fields beyond the required one shown — anything else you pass through is preserved.

disclosure is a TOP-LEVEL field, not part of personaContext. Every call discloses, in its opening turn, that the callee is speaking with an AI and that the call is recorded — this cannot be turned off, by any field, anywhere in the request. disclosure only lets you supply your OWN wording for that sentence instead of the built-in default; it can never remove the disclosure itself. A supplied value is validated (20-300 characters, consistent with counterpartContext. language, a plain-text character allowlist, and an active disclosure-practice attestation on file for your organization) and, when it fails any of that, the built-in default is substituted for THIS call instead — not silently: the request still succeeds, it just did not get your wording, and the response's disclosure field (see below) always tells you which happened, so check it rather than assuming your wording was used.

counterpartContext.phoneNumber must be E.164, digits only — a leading + followed by the calling code and national number with no spaces, dashes, or other punctuation (e.g. +37060000000, not +370 6000 0000 or +370-6000-0000). The schema does not itself reject a non-conforming string, but the server derives the call's billing destination country from it by pattern-matching the digits; a value carrying anything other than digits after the + fails that match and the call is refused as an unpriced destination rather than placed. Send digits only.

situationalContext, if present, is a partial override of Ringspine's computed "today" context — every field is optional and unspecified ones are filled in server-side:

Field Type
currentDateTimeIso string
currentDateTimeHuman string
dayOfWeek one of Sunday..Saturday
timezone string
location string or null
newsOfTheDay string or null

If phoneNumberId is omitted, the server chooses the cheapest of whatever outbound numbers are configured. If none is configured at all, or none has a known cost for this destination, the request fails — see the error table below.

Response — 201 Created

{
  "id": 123,
  "status": "pending",
  "createdAt": "2026-08-07T12:00:00.000Z",
  "disclosure": { "source": "default" }
}

disclosure.source is "custom" when your disclosure text was used verbatim, or "default" when it was not — either because you didn't supply one, or because what you supplied was rejected and the built-in default was substituted instead. When it was rejected, a disclosure. rejectedReason field is also present, one of: empty, too-short, too-long, language-mismatch, unsafe, or unattested (your organization has no active disclosure-practice attestation on file — contact support). The call is placed either way — a rejected disclosure never fails the request, it only means the sentence actually spoken was the default, not yours.

Nothing else is returned — no persona/counterpart/situational echo, no vendor call config. Poll GET /api/v1/calls/:id for the result (see "Results arrive asynchronously" below).

curl example

Needs a real minted key — replace rs_live_REPLACE_ME with your own.

curl -s -X POST https://ringspine.com/api/v1/calls \
  -H "Authorization: Bearer rs_live_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "personaContext": { "name": "Alex" },
    "counterpartContext": { "phoneNumber": "+37060000000" },
    "objective": "Ask if they saw the follow-up email and what they thought."
  }'

GET /api/v1/calls

Your organization's call history, newest first, cursor-paginated.

Query parameters

Parameter Type Required Default
limit integer, 1–100 no 20
cursor opaque string, copied verbatim from a previous response's nextCursor no first page

limit outside 1–100, zero, negative, non-numeric, or empty is a 400 — see the error table below. It is never silently clamped: a clamped limit=1000 would look identical to a complete response while actually being truncated.

cursor is opaque — do not decode it, construct one yourself, or expect its shape to stay stable across a Ringspine version. Only ever pass it back exactly as a previous response's nextCursor gave it to you. A cursor that is malformed, undecodable, or addresses a value your organization could never have produced is a 400, never a 500.

Response — 200 OK

{
  "calls": [
    {
      "id": 53,
      "createdAt": "2026-08-07T12:00:00.000Z",
      "status": "completed",
      "objective": "Ask if they saw the follow-up email and what they thought.",
      "counterpartName": "Sam",
      "counterpartPhone": "+37060000000",
      "redacted": false,
      "billableSeconds": 239.6
    }
  ],
  "nextCursor": null
}
Field Type Notes
id integer
createdAt ISO-8601
status "pending" | "in_progress" | "completed" | "failed"
objective string
counterpartName / counterpartPhone string or null Whatever was recorded for the call, or null if never set.
redacted boolean true once retention/erasure has cleared this call's personal data — counterpartName/counterpartPhone read null either way, so this is how you tell "erased" apart from "never had a name".
billableSeconds number or null May be fractional (e.g. 239.6) — a duration-cap or vendor-reported figure is not always a whole second. null until a call is billed.

nextCursor is a string when a further page exists, or null on the last page — never omitted, never an empty string.

An organization with no calls yet gets 200 { "calls": [], "nextCursor": null }, never a 404. A brand-new organization having no history is the normal first state.

curl example

curl -s "https://ringspine.com/api/v1/calls?limit=20" \
  -H "Authorization: Bearer rs_live_REPLACE_ME"

GET /api/v1/calls/latest

Your organization's single most recent call, including its transcript, summary and extracted signals — everything GET /api/v1/calls/:id would return for it, without needing to know its id first.

A separate route, not a query parameter on GET /api/v1/calls — its response carries a wider field set (the full transcript/summary/signals) than a list row does, and a parameter that changes a response's shape from an array to a single object is harder to use from a generated client.

Response — 200 OK

{
  "call": {
    "id": 53,
    "createdAt": "2026-08-07T12:00:00.000Z",
    "status": "completed",
    "objective": "Ask if they saw the follow-up email and what they thought.",
    "counterpartName": "Sam",
    "counterpartPhone": "+37060000000",
    "redacted": false,
    "billableSeconds": 239.6,
    "transcript": "Hi, is this Sam? ...",
    "summary": "Saw the email, hasn't read it properly. Asked to be called back next week.",
    "extractedSignals": {
      "interestLevel": "medium",
      "objections": ["price"],
      "callbackTime": "next week"
    }
  }
}

The fields are exactly GET /api/v1/calls's per-row fields (see the table above) plus transcript, summary and extractedSignals — see GET /api/v1/calls/:id below for what those three mean and their exact rules (a call still pending has both null).

An organization with no calls at all gets 200 { "call": null }, never a 404.

One property worth knowing rather than relying on: this reflects the latest call as of when Ringspine received your request — if a newer call is placed a moment later, that is a normal consequence of polling a live system, not a bug.

curl example

curl -s https://ringspine.com/api/v1/calls/latest \
  -H "Authorization: Bearer rs_live_REPLACE_ME"

GET /api/v1/calls/:id

Reads back a previously placed call, scoped to your organization. :id is the integer id returned by the POST above.

Response — 200 OK

{
  "status": "pending",
  "transcript": null,
  "summary": null,
  "extractedSignals": null
}

Once the call has ended, the same endpoint returns the populated shape:

{
  "status": "completed",
  "transcript": "Hi, is this Sam? ...",
  "summary": "Saw the email, hasn't read it properly. Asked to be called back next week.",
  "extractedSignals": {
    "interestLevel": "medium",
    "objections": ["price"],
    "callbackTime": "next week",
    "disclosureReaction": {
      "reaction": "curious",
      "hungUpNearDisclosure": false
    }
  }
}

summary is a short prose summary of the conversation, generated from the call. Calls placed before summary generation shipped instead carry a one-line placeholder stating interest level and objection count; those read obviously differently, and transcript always has the substance.

Every field inside extractedSignals is optional and any of them may be absent — interestLevel is a string or a number, objections an array of strings, callbackTime a string, and disclosureReaction.reaction one of neutral / curious / negative / not_mentioned. Treat a missing key as "not detected" rather than as an error; disclosureReaction in particular is absent on older calls. extractedSignals itself is null until the call ends.

This is the complete, explicit field set — nothing else on the call row (persona/counterpart/ situational context, vendor call ids, costBreakdown, latencyStats, orgId, timestamps) is ever included in this response, including any field added to the underlying table later.

A call id belonging to another organization returns 404, not 403. This is deliberate: a 403 would confirm the id exists under some other tenant. Both "never existed" and "exists, but isn't yours" are indistinguishable 404s.

curl example

Needs a real minted key — replace rs_live_REPLACE_ME and 123 with your own.

curl -s https://ringspine.com/api/v1/calls/123 \
  -H "Authorization: Bearer rs_live_REPLACE_ME"

GET /api/v1/usage

Your organization's usage for a billing period, so you can see spend without waiting for an invoice.

Read straight from the usage ledger — the same records an invoice is built from — not from the billing provider, which is refreshed by a daily sweep and would be up to a day behind.

Query parameters

Parameter Type Required Default
period YYYY-MM (UTC calendar month, year 1000-9999) no the current UTC month

Response — 200 OK

{
  "period": "2026-08",
  "periodStart": "2026-08-01T00:00:00.000Z",
  "periodEnd": "2026-09-01T00:00:00.000Z",
  "billableCalls": 3,
  "billableMinutes": "1.3000",
  "amounts": [{ "currency": "EUR", "amount": "1.3000" }],
  "limits": {
    "callsAgainstCap": 7,
    "monthlyCallCap": 200,
    "maxConcurrentCalls": 3,
    "rateLimitPerMinute": 20
  }
}
Field Type Notes
period YYYY-MM The period these figures cover.
periodStart / periodEnd ISO-8601 Half-open: periodStart is included, periodEnd is not. periodEnd is the first instant of the next month.
billableCalls integer Calls with a ledger record in the period.
billableMinutes string Exact decimal — see "Money and duration values are JSON strings" near the top of this document.
amounts array of { currency, amount } One entry per currency, sorted by currency. Empty when there is no usage.
limits.callsAgainstCap integer Calls counted against your monthly cap. Not the same as billableCalls — see below.
limits.monthlyCallCap integer Your cap for the month.
limits.maxConcurrentCalls integer How many of your calls may be in progress at once. A call placed past this is refused; the others keep running.
limits.rateLimitPerMinute integer Your per-minute request limit. Not a concurrency limit — it caps how fast calls start, not how many run.

amounts and billableMinutes are JSON strings, not numbers — see "Money and duration values are JSON strings, not numbers" near the top of this document; that rule now governs GET /api/v1/rates and GET /api/v1/balance too, so it is stated once, not per route.

amounts is a list because a currency is never assumed. Each ledger record carries the currency it was priced in, and adding two currencies together produces a number that is not money. In practice you will see exactly one entry.

billableCalls and limits.callsAgainstCap are different numbers and are expected to disagree. The cap is consumed when a call is accepted — before it connects, before it is rated. A call that never connected consumed a cap slot and produced no ledger record. So billableCalls answers "what will I be invoiced for" and callsAgainstCap answers "how much of my cap is left".

A period with no usage returns zeroes, not a 404. billableCalls is 0, billableMinutes is "0.0000", and amounts is []. You do not need to special-case a quiet month.

curl example

curl -s "https://ringspine.com/api/v1/usage?period=2026-08" \
  -H "Authorization: Bearer rs_live_REPLACE_ME"

GET /api/v1/rates

The per-minute rates every organization is currently charged, by lane.

⚠️ A PRICED LANE IS NOT NECESSARILY A DIALABLE ONE. This route lists what is priced. POST /api/v1/calls separately checks whether a destination is legally cleared to dial, and several lanes below are priced but not yet cleared — a request to one of them is refused with 400 { "error": "This destination is not currently available for calling (<origin> -> <dest>)." } even though this route shows it a price. This particular 400 carries no reason key at all — see the error table below. Do not treat a row here as a promise that POST /api/v1/calls will accept that destination. There is no route on this API today that tells you both facts at once.

Response — 200 OK

{
  "rates": [
    {
      "originCountry": "LT",
      "destCountry": "LT",
      "pricePerMinute": "0.2900",
      "currency": "EUR",
      "billingIncrementSeconds": 60
    }
  ]
}
Field Type Notes
originCountry / destCountry ISO-3166 alpha-2 The lane.
pricePerMinute string Exact decimal — see "Money and duration values are JSON strings" near the top of this document.
currency ISO-4217, uppercase
billingIncrementSeconds integer How finely a call on this lane is billed (e.g. 60 = billed to the nearest minute).

Every organization sees the identical list — pricing is global, not per-tenant. Authentication is still required: an unauthenticated price list is a scrapeable commercial surface even though nothing here is org-specific.

A quiet rates: [] is not proof that nothing is priced. A malformed rate card is dropped from this list rather than shown broken, so an empty response can also mean every lane is currently unpriceable — either way it is a 200, not a 404 or 500.

curl example

curl -s https://ringspine.com/api/v1/rates \
  -H "Authorization: Bearer rs_live_REPLACE_ME"

GET /api/v1/balance

Your organization's prepaid credit balance — "what budget is left". Ringspine has no monthly plan and nothing resets: you top up credit via the dashboard, and each call draws down against it.

Response — 200 OK

{
  "currency": "EUR",
  "balance": "27.9000",
  "reserved": "2.9000"
}
Field Type Notes
currency ISO-4217, uppercase Always the single currency credit is denominated in.
balance string What you have left to spend, right now. Exact decimal — see "Money and duration values are JSON strings" near the top of this document. Can be negative (an overspent organization) — never clamped to zero.
reserved string Money already deducted from balance for calls still in progress, not yet settled — not an amount still sitting inside balance that you could also spend. balance already reflects every open hold, so do not compute balance - reserved as what you can spend right now; that subtracts the hold twice. Use reserved only to understand why balance moved: it releases (and balance may move back up) once the call ends and its real cost is known. "0.0000" when nothing is in flight.

A zero balance renders as "0.0000", not "0" or a JSON number 0. A negative balance renders as-is, with the leading -. This route never refuses — reading a zero or negative balance always succeeds; it is POST /api/v1/calls that refuses to place a call once your balance reaches zero.

curl example

curl -s https://ringspine.com/api/v1/balance \
  -H "Authorization: Bearer rs_live_REPLACE_ME"

Results arrive asynchronously

POST /api/v1/calls returns as soon as the call is queued — status is pending at that point, and transcript/summary/extractedSignals are all null. The call itself runs in real time against Ringspine's calling infrastructure, and only once it ends does an asynchronous result fill in the outcome: status moves to completed (call connected) or failed (it never connected), and transcript/summary/extractedSignals are populated in that same update. There is no push/webhook surface for tenants — poll GET /api/v1/calls/:id until status is terminal.

Treat completed and failed as the terminal states and anything else as non-terminal, rather than polling "until not pending" — that way a future intermediate state doesn't read as a finished call to your integration.

On a deployment not configured for live calling, the status never changes. POST builds the call but places none, so no result ever arrives and the row stays pending indefinitely. Polling will not terminate. Confirm you're pointed at a live-calling deployment before building a retry loop around it.

Error table

Status Route(s) Body Meaning
401 Unauthorized all { "error": "Unauthorized" } Missing, malformed, unrecognized, or revoked API key.
400 Bad Request POST { "error": "Invalid JSON body" } Body isn't parseable JSON.
400 Bad Request POST { "error": "Invalid request body", "issues": [...] } Body parsed but failed schema validation; issues is the raw Zod issue list.
400 Bad Request POST { "error": "phoneNumberId is required (pass it explicitly; no default outbound number is configured for this environment)." } No phoneNumberId given and no outbound number configured in this environment at all.
400 Bad Request POST { "error": "No configured origin has a known cost for <phoneNumber> -- the rate deck may be missing or not cover this destination." } An outbound number is configured, but none has a known cost for this destination — an operator/config gap, not something you can fix by retrying.
400 Bad Request POST { "error": "The lowest-cost origin for <phoneNumber> is a <XX> number (E.164 calling code +<code>), which is not configured in this environment." } The cheapest known origin for this destination is a real, priceable one that was never provisioned here — an operator/config gap, not something you can fix by retrying.
400 Bad Request POST { "error": "We do not currently serve this destination.", "reason": "unsupported-destination" } The cost-ceiling/anomaly guard has no cost data for this destination at all. This could become supported later — no action to take beyond asking.
400 Bad Request POST { "error": "This is a premium or high-cost range we do not serve.", "reason": "premium-destination" } The cost-ceiling/anomaly guard refused this destination: its real per-minute cost exceeds Ringspine's ceiling, or it carries a connection fee. This will not become supported — no cost or rate figure is ever returned, by design.
400 Bad Request POST { "error": "This destination is not currently available for calling (<origin> -> <dest>)." } No reason key on this row, unlike the two above it. The destination is priced (it may even appear in GET /api/v1/rates) but is not legally cleared to dial — a different guard from the cost-ceiling one, and the real reason is deliberately not returned to the caller. See the GET /api/v1/rates warning above.
400 Bad Request POST { "error": "That phoneNumberId is not recognized.", "reason": "unrecognized-origin" } An explicit phoneNumberId was given that matches none of the outbound numbers currently configured, so the cost-ceiling guard has nothing to reason a cost about. Check the value you passed — a real, provisioned phoneNumberId resolves normally.
400 Bad Request GET /calls/:id { "error": "Invalid call id" } :id isn't a plain non-negative integer, or exceeds the int4 max.
400 Bad Request GET /calls { "error": "Invalid limit" } limit is out of the 1–100 range, zero, negative, non-numeric, or an empty string. Never silently clamped.
400 Bad Request GET /calls { "error": "Invalid cursor" } cursor is undecodable, not valid JSON once decoded, fails schema validation, or addresses an id beyond what your organization could have produced. Always echo nextCursor back verbatim rather than constructing your own.
400 Bad Request GET /usage { "error": "Invalid period. Expected YYYY-MM." } period isn't a YYYY-MM UTC month. Note a month with no usage is not an error — it returns zeroes.
404 Not Found GET /calls/:id { "error": "Not found" } No call with that id in your organization — either it doesn't exist at all, or it belongs to a different tenant (see above).
402 Payment Required POST { "error": "Credit balance is <balance> <currency>. Top up to place calls.", "reason": "insufficient-balance" } — or, when your balance is positive but too small to fund even one billing increment on this lane: { "error": "Credit balance of <balance> <currency> is below the minimum billable <n>s for this destination. Top up to place calls.", "reason": "insufficient-balance" } Your organization's prepaid credit balance will not fund this call. Not retryable until you top up — see GET /api/v1/balance.
403 Forbidden POST { "error": "This organization is suspended pending review of an unexpected vendor cost. Contact support to be reinstated.", "reason": "org-suspended" } A completed call's actual cost, reported after the call and separately from the pre-dial guards above, exceeded Ringspine's ceiling. Not retryable: only an operator can lift the suspension. A call already in progress when the breach was detected is not affected; this only blocks the next one.
429 Too Many Requests POST { "error": "Rate limit exceeded" } or { "error": "Monthly call cap reached" }, Retry-After header in seconds A usage limit was hit — see below. No call is placed.
429 Too Many Requests POST { "error": "This organization already has <n> calls in progress, which is its limit of <max>.", "reason": "concurrency-cap" } — no Retry-After header Too many of your calls are already in progress at once (GET /api/v1/usage's limits.maxConcurrentCalls). Not a fixed usage window like the row above, so there is no reset time to report — wait for one of your in-flight calls to finish instead of reading a header that isn't there.
503 Service Unavailable POST { "error": "Ringspine has not launched yet. You can explore the dashboard, but calls and payments are not available." } This environment has not launched. No call is placed and no cap slot is spent. Not retryable on a timer; retry once the environment has launched.
500 Internal Server Error all { "error": "Internal server error" } An unexpected failure (e.g. a transient DB error, or the call-placement step itself failing for a reason other than a missing phone number id).

No GET route can return a 429 — reads aren't rate-limited or cap-counted. No GET route can return a 503 for being unlaunched either — the launch gate exists to stop spend, and reading your own call history, latest call, usage, rates, or balance in an unlaunched environment spends nothing.

Empty is always a 200, never a 404, across every GET route that lists or aggregates: GET /calls with no history returns { "calls": [], "nextCursor": null }; GET /calls/latest with no history returns { "call": null }; GET /usage with no usage in the period returns zeroes; GET /rates with no priced lanes returns { "rates": [] }; GET /balance for an organization that has never moved money returns { "currency": "EUR", "balance": "0.0000", "reserved": "0.0000" }. 404 on this API means specifically "this id doesn't exist, or it isn't yours" (GET /calls/:id only) — it is never reused for "there happens to be nothing here yet".

Rate limits and the monthly cap

Two independent, per-organization limits apply to POST /api/v1/calls only (GET is unlimited):

  • Request rate — default 20 requests/minute.
  • Monthly call cap — default 200 placed calls/month.

Both are per-organization limits, not global constants — either can be raised for your organization on request.

Both windows are fixed (tumbling), not rolling, and derived from UTC. This means a burst that straddles a window boundary — e.g. one request at :59.9 and another at :00.1 of the next minute — can briefly get up to 2x the configured per-minute limit across the two windows. This is an accepted property of the scheme, not a bug.

The rate limit is checked before your request body is even parsed, so a malformed or schema-invalid request still consumes a rate-limit slot. The monthly cap, by contrast, is only consumed once your request has passed auth, the rate limit, and full schema validation — so it only ever counts requests that were genuinely about to place a call.

Both the request-rate and monthly-cap 429s above carry a Retry-After header (seconds until the limit resets) and place no call. A third 429 exists — the concurrency cap, see the error table above — and it is not a fixed window with a reset time, so it carries no Retry-After header at all; do not assume every 429 from this API has one.

One consequence worth planning for: a monthly-cap slot is consumed at validation time and is not refunded if the request then fails — a missing phoneNumberId (400) or an internal failure during placement (500) still spends the slot. Failing closed is deliberate, since a failed placement can already have cost money upstream.

What is deliberately not exposed

No cost or latency data is returned by this API, on any route, under any circumstance. That includes GET /api/v1/usage: amounts is what you are billed, never what a call cost Ringspine to place.

GET /api/v1/rates never exposes effectiveFrom, effectiveTo, createdAt, or a rate card's internal id. effectiveTo in particular would announce a scheduled future price change before Ringspine has told you about it directly — this route only ever shows the rate in effect right now, never a preview of what changes next.

GET /api/v1/balance never exposes credited. It is tempting to assume credited means "what you've topped up" — it does not. It sums every kind of ledger movement, including an open, not-yet-settled hold for a call still in progress, so it can read lower than your actual top-up total while a call is running. Exposing it under any field name would hand you a number that looks like "topped up" and isn't. If you need your top-up history, read it from your own payment records — Ringspine does not expose a raw top-up ledger over this API today.

GET /api/v1/balance also never exposes consumed — GET /api/v1/usage already answers "what have I spent"; this route answers only "what is left".