Surge Documentation Audit
The Surge docs are reasonably well-organized at the URL level (a clean llms.txt exists, every endpoint has its own page, embedded UI components are documented separately from the REST API), but the substance is uneven. The OpenAPI spec is doing most of the work, several documentation pages are visibly truncated mid-example, every webhook event page is an empty stub, and a handful of schema/prose contradictions will cost developers real time.
1. Every webhook event page is an empty stub (critical)
Location: /api-reference/webhooks/* — all 13 event pages (message.sent, message.delivered, message.failed, message.received, call.ended, campaign.approved, contact.opted_in, contact.opted_out, conversation.created, link.followed, phone_number.attached_to_campaign, recording.completed, voicemail.received)
Problem: Each event page contains nothing but a one-line description and an empty ## OpenAPI heading. For example, the entire message.delivered page reads:
"The
message.deliveredevent is delivered whenever a message sent from a Surge number is successfully delivered to the recipient..."## OpenAPI
No payload schema, no example body, no field-level documentation appears under that heading on any of the 13 pages. The problem compounds for call.ended: the Recording.call.status enum on /recordings/get includes nine values (busy, canceled, completed, failed, in_progress, missed, no_answer, queued, ringing), but the call.ended event page is a stub — so it's impossible to tell whether call.ended fires for all terminal states or only some.
Consequence: A developer building a webhook handler cannot learn what fields arrive in the payload from the docs at all — not the JSON shape, not whether timestamps are ISO8601 or Unix, not whether nested objects (contact, phone_number, conversation) are present. They have to guess, send a test webhook, log it, and reverse-engineer the shape. For an AI agent indexing these pages, the entire webhook surface area is invisible.
The fix: Render the actual OpenAPI event schemas under the ## OpenAPI heading on every event page, with at least one concrete example payload and a field-by-field table. For call.ended specifically, document which call.status values trigger the event.
2. Webhook signature validation page is truncated mid-explanation (critical)
Location: /api-reference/webhooks/signature-validation
Problem: The page introduces the Surge-Signature header — "Each webhook Surge delivers includes a 'Surge-Signature' header that looks like this:" — and then ends. No header example, no algorithm, no code sample. The aggregated llms-full.txt summary mentions t (Unix timestamp) and v1 (HMAC-SHA256 lowercase hex) fields and the timestamp.raw_body signing string, but the canonical validation page itself doesn't show them.
Consequence: Validation is the one thing the webhook intro page tells you to do (<Warning>...validating webhook signatures...</Warning>) — and the linked page that's supposed to explain it stops before the explanation. Developers will either skip validation (security risk the docs explicitly warn against) or burn time piecing the algorithm together from the summary file and emails to support.
The fix: Publish the full content: Surge-Signature header format (t=...,v1=...), the exact string to HMAC (timestamp.raw_body), the signing-secret source, and copy-paste validation snippets in at least Node and Python. Add a tolerance window for replay protection if one isn't already enforced server-side.
3. Multiple UI-components pages are truncated mid-example (critical)
Location: /ui/authentication, /ui/components/inbox, /ui/components/conversation, /ui/components/unread-count
Problem: Four UI-related pages all end mid-sentence at the point where an example or URL was supposed to appear:
/ui/authenticationends at "An inbox URL, for example, will look like this:" with no URL./ui/components/inboxends at "The simplest version of the inbox component can be embedded like this:" with no embed snippet./ui/components/conversationends at the heading### With a conversation IDwith no example following./ui/components/unread-countends at "The simplest version of the unread count component can be embedded like this:" with no embed snippet.
This is the same failure mode as the signature-validation page (Issue #2) — five truncations in total — and suggests a single rendering/templating bug rather than five separate authoring oversights.
Consequence: The entire embedded-UI product is documented as four pages that describe what's coming and then stop before showing it. A developer trying to embed an inbox or unread-count widget has to reverse-engineer the iframe URL from the llms-full.txt summary (which does list them: https://embed.surge.app/conversations?user_id={USER_ID}&token={TOKEN}...). Agents have no canonical, parseable code to extract.
The fix: Investigate the underlying renderer — almost certainly the <CodeBlock> or <Tabs> component fails to emit when its children are absent or malformed — then republish each page with the actual example. The canonical iframe URLs already exist in llms-full.txt; promote them into the rendered pages.
4. send_at scheduling window: "60 days" vs "a couple of months" (significant)
Location: /api-reference/endpoint/messages/create
Problem: The prose at the top of the page says:
"If you would like to schedule sending for some time up to 60 days in the future, you can do that by providing a value for the
send_atfield."
The schema descriptions for send_at (in both MessageParamsWithConversation and SimpleMessageParams) instead say:
"An optional datetime for scheduling message up to a couple of months in the future."
60 days is precise; "a couple of months" is ambiguous (60? 62? 90?). The BlastParams example also uses send_at: '2024-02-01T15:00:00Z' with no scheduling-window description on the Blast schema field at all, so the question recurs.
Consequence: A developer scheduling reminders near the boundary (a 9-week appointment reminder, say) cannot tell from the docs whether the request will succeed or be rejected. SDK generators will copy whichever string they hit first.
The fix: Pick the real limit, say it once in concrete units (e.g. "up to 60 days / 5,184,000 seconds in the future"), and copy the same wording into every send_at description across messages, blasts, and any other scheduled endpoint.
5. Campaign status example uses a value not in the enum (significant)
Location: /api-reference/endpoint/campaigns/create
Problem: The Campaign schema example shows status: pending, but the same schema's status.enum lists only active, canceled, created, deactivated, in_review, rejected. pending is not a permitted value. The required arrays correctly distinguish StandardCampaignParams (request) from Campaign (response, which requires status), so the bug is purely the example value, not the requirement model.
Consequence: Anyone writing a state machine around campaign status will either handle a pending they'll never see, or be missing a state they do see — depending on which side of the contradiction is wrong. Agents copying the example into tests will produce fixtures that the enum rejects.
The fix: Decide whether pending is a real status and add it to the enum, or correct the example to use whichever value is actually returned on campaign creation (likely created or in_review). Then audit every other example payload for the same drift.
6. DELETE /users/{id} returns the (now-deleted) User object (significant)
Location: /api-reference/endpoint/users/delete
Problem: The endpoint description says "Once a user has been deleted, they will no longer be permitted to access any of the embedded components. Attempting to access a deleted user will return a 404 Not Found error." — yet the 200 response schema is $ref: '#/components/schemas/User' and the response description is literally "Deleted user." The response example itself shows no deletion marker (no deleted_at, no tombstone status field) that would let a caller distinguish a deletion from a retrieval.
Consequence: A developer can't tell, from the returned object alone, whether the call actually deleted the user or just fetched them. SDK consumers will write code like if (response.id) { /* deleted */ } and have no idea they've done a soft-delete-with-side-effects rather than received a deletion confirmation.
The fix: Either return 204 No Content, or add a clear deletion marker to the response (a deleted_at timestamp at minimum) and document it. The text already implies a soft-delete-with-404-lookup behavior — say so explicitly.
7. BlastParams.required: [] despite obvious mandatory fields (significant)
Location: /api-reference/endpoint/blasts/create, /api-reference/endpoint/accounts/update, /api-reference/endpoint/phone-numbers/purchase
Problem: The BlastParams schema declares required: []. But the example shows body, from, name, and to all populated, and the prose elsewhere makes clear a blast must have recipients and either a body or attachments. The same empty-required pattern appears on AccountUpdateParams and PhoneNumberPurchaseParams, both of which have semantic requirements (the phone-number page literally describes inference rules: "If only area_code is provided, type is inferred...; Otherwise, type must be explicitly specified").
Additionally, BlastParams ships two deprecated fields (contacts, segments) alongside their replacement (to) with no migration prose on the page — only the x-stainless-deprecation-message extension flag, which most documentation consumers won't see.
Consequence: The schema doesn't tell readers what the server will actually enforce. Developers learn the real requirements from runtime 400s rather than the docs. Anyone copying the deprecated contacts/segments arrays from older examples gets no inline warning on the page.
The fix: Express the true requirements in OpenAPI — oneOf/anyOf for "body or attachments," explicit required arrays, and conditional schemas for the phone-number inference rules. Add a visible deprecation banner in the page body, not just an extension tag.
8. PhoneNumber.type enum drift between purchase params and response (significant)
Location: /api-reference/endpoint/phone-numbers/purchase
Problem: PhoneNumberPurchaseParams.type enum is {local, toll_free} — you cannot purchase a short_code. But the PhoneNumber response schema's type enum is {local, short_code, toll_free}. So a response may carry a value that the request schema rejects, and nothing in the scraped docs explains how short codes are provisioned.
Consequence: Developers writing typed clients see short_code as a possible return value and assume they can request it — then get an enum-validation error at request time. Anyone needing short codes is left without an in-docs path: no guide, no separate endpoint, no support note.
The fix: Either remove short_code from the response enum (if it's never returned via this API) or add a dedicated section on how short codes are obtained — likely a manual provisioning process — and link to it from both schemas.
9. Verification 422 examples contradict the Verification.status enum semantics (significant)
Location: /api-reference/endpoint/verifications/check
Problem: The Verification.status enum lists exactly four values: pending, verified, exhausted, expired. The 422 examples on the same page show a verification object with attempt_count: 1 and status: pending returned alongside result: incorrect — and a separate Exhausted example where the top-level result is incorrect while the nested verification's status is exhausted. The relationship between result and verification.status is never stated.
Consequence: A developer cannot infer the state machine: does result: incorrect always mean status: pending? When does status flip to exhausted — on the attempt that triggers it or on the next call? The examples imply both. SDK error handling has to guess.
The fix: Document the relationship between result and verification.status in a small table, and audit each 422 example so that the pair is internally consistent.
10. No API authentication page (significant)
Location: Docs site overall — /api-reference/introduction and the carrier-registration guide
Problem: Every example request in the carrier-registration guide uses -H "Authorization: Bearer <token>", but nothing in the scraped content explains where that token comes from, how it's scoped, whether it expires, or how to rotate it. The /ui/authentication page covers tokens for embedded UI components (publishable vs signed user tokens) and explicitly contrasts them — but it doesn't double as the REST API auth page, and the API reference doesn't link to a dedicated auth doc.
Consequence: A new developer following the carrier-registration guide hits "Bearer <token>" in step 1 and has no in-docs path to learn how to obtain that token. Agents indexing the docs have no canonical answer to "how does Surge auth work" — the closest hit (/ui/authentication) is about iframe tokens, not API tokens, and will mislead.
The fix: Add a /api-reference/authentication page covering: how to mint API keys, scoping (account vs platform), rotation, revocation, expected Authorization header format, and 401 behavior. Link to it from every endpoint page.
11. No rate-limit, errors, or status-code reference (significant)
Location: Docs site overall
Problem: Across the scraped content (including llms.txt and llms-full.txt), there is no page covering rate limits, no global errors page, and no enumerated list of HTTP status codes the API returns. Error examples exist only inline on individual endpoints (e.g. the verifications/check 409 and 422 blocks). The webhook intro mentions retries (up to 20 with exponential backoff and jitter) but never says what response codes trigger a retry vs. a drop, beyond accepting 200 or 201 as success.
Consequence: Production-grade integrations need to know: what does Surge do on 429? Is there a Retry-After? What does a 4xx response body look like? Which 5xx codes are retryable? Without a central reference, every developer reverse-engineers this from incidents. Agents writing resilient client code have to invent a convention.
The fix: Add /api-reference/errors (canonical error envelope, full status-code matrix, retryability) and /api-reference/rate-limits (per-account vs per-endpoint limits, headers returned, 429 behavior). Reference both from every endpoint page.
12. The "Guides" section is a single page (significant)
Location: /guides/* — only /guides/carrier-registration/register-a-campaign exists in the index
Problem: The marketing site advertises "Developer-Friendly API," embedded UI components, no-code blasts, link shortening, and planned RCS/WhatsApp channels — but the entire guides/ namespace in the scraped index contains exactly one walkthrough (carrier registration). There is no "sending your first message" guide, no webhook-setup guide, no embedded-components quickstart, no link-shortening explainer, no opt-in/opt-out compliance walkthrough.
Consequence: Once a developer is past carrier registration, the docs flip from narrative mode to pure endpoint reference. The next steps — wiring webhooks, embedding the inbox component, handling opt-outs — have no end-to-end story, only individual endpoint pages that don't show how the pieces connect.
The fix: Expand /guides/ with at minimum: "Send your first message," "Receive and validate webhooks," "Embed the inbox component," "Handle opt-ins and opt-outs." Each should be a runnable, end-to-end walkthrough like the carrier-registration page already is.
13. Pagination cursor format and limits are undocumented (minor)
Location: /api-reference/endpoint/contacts/list (the only place pagination is described in any detail)
Problem: The contacts/list page documents after and before query params and the Pagination schema (with next_cursor and previous_cursor), and shows an example cursor g3QAAAABZAACaWRtAAAAGnBuXzAxamtzY2s5eDdkeW0wZnBxZjdjYmRyeQ==. But the cursor format is never explained (opaque vs. structured?), there's no limit/page_size parameter documented, no default page size stated, and no maximum. The aggregated llms-full.txt summary explicitly notes "specific cursor format not detailed."
Consequence: Developers writing pagination loops can't decide whether to treat the cursor as opaque (safe) or parseable, can't set a limit to control payload size, and don't know when they've reached the last page beyond a null next_cursor.
The fix: Add a shared "Pagination" section in the API reference (linked from every list endpoint) covering: cursor opacity, default/min/max page size, the limit parameter if it exists, and ordering guarantees.
14. add_contact references "manual audience" with no definition (minor)
Location: /api-reference/endpoint/audiences/add_contact
Problem: The endpoint says "Adds an existing contact to a manual audience." The Create audience endpoint exists in the index but the term "manual audience" is not defined anywhere in the scraped content — implying there are other audience kinds (dynamic? rule-based?) that aren't documented either.
Consequence: A developer creating an audience can't tell whether they're getting a "manual" one (and therefore can use add_contact) or some other kind that this endpoint will 4xx on. The workflow appears silently incomplete.
The fix: Document the audience types on the Create audience page, mark which type the example creates, and link add_contact back to that section.
15. Carrier-registration guide ships invalid JSON in the Update account example (minor)
Location: /guides/carrier-registration/register-a-campaign
Problem: The Update account request body in the guide ends with a trailing comma:
"registered_name": "DT Precision Auto LLC",}
A trailing comma before the closing brace is invalid JSON.
Consequence: A developer copy-pasting the example into a JSON-strict client (or piping it through jq, or letting an AI agent extract and run it) will get a parse error before ever hitting the Surge API. Since this is the only end-to-end guide in the docs, the bug sits on the most-trafficked code path.
The fix: Remove the trailing comma. Run every JSON block in the guides through a validator in CI.
What they do well
llms.txtexists and is structured — most endpoints have a one-line description and a stable URL, which is exactly what agent ingestion needs (coverage is uneven — severalphone-numbers,recordings, andusersentries are bare links — but the file's existence is a real asset).- The carrier-registration guide is genuinely good — concrete payloads, real-looking IDs, step-by-step
<Steps>flow. It's the model the rest of/guides/should follow. - UI-component auth tradeoffs are stated as a table — the publishable-vs-signed-token comparison on
/ui/authenticationis the kind of decision aid most SMS APIs bury (when the rest of the page renders).
Top 3 recommendations
- Fix the rendering pipeline so that every
<CodeBlock>/example placeholder actually emits content — five truncated pages (signature-validation,ui/authentication,inbox,conversation,unread-count) point at a single underlying bug. Then populate every webhook event page with its payload schema and a concrete example. This is the single largest dark area in the docs. - Add three missing reference pages: API authentication (where API keys come from, scoping, rotation), errors (canonical envelope + status-code matrix), and rate limits (limits + headers + 429 behavior). Link from every endpoint.
- Reconcile schema/prose contradictions:
pendingin the Campaign status example,send_atwindow ("60 days" vs "a couple of months"),DELETE /usersreturn shape,PhoneNumber.typeenum drift between purchase and response, and therequired: []arrays that hide real semantic requirements. Run a one-time pass to confirm every example payload validates against its own schema.