- Reference
- Integration reference
Reference
Integration reference
Idempotency, delivery states, failure handling, test outcomes, and tenant-isolation rules for production integrations.
WhooshBang integration reference
What the quickstart deliberately skipped: the rules you need once a send sits behind a retrying job queue and a real user is waiting on it.
Every request, response, and problem document here is checked against the
published V1 contract by npm run docs:snippets.
Idempotency
Every send requires an Idempotency-Key. Choose one derived from the thing
that caused the notification — the job ID, the order ID, the event ID — not a
fresh random value per attempt. A random key per attempt makes the header
useless, because a retry looks like a new message.
Repeating a send with the same key and an equivalent payload returns the original message, unchanged. No second message is created and no second Telegram notification appears. Reading the state of the returned message tells you what happened to the original.
Reusing the same key with materially different input is a conflict:
{
"type": "https://whooshbang.flowxo.com/problems/idempotency_conflict",
"title": "Idempotency conflict",
"status": 409,
"detail": "The key was already accepted with materially different validated input.",
"code": "idempotency_conflict",
"diagnostic_id": "diag_idem_conflict_001",
"retryable": false
}
That is a bug in the caller, not a transient failure — retrying it will never succeed. Either you reused a key you should not have, or you changed a message you had already committed to sending.
Keys are scoped to the authenticated environment and the operation. The same key on a test send and a live send are unrelated.
Credential create and rotate take no idempotency key. Their secret is a one-time reveal, so there is nothing safe to replay: if the call fails in transit, issue a replacement and revoke the one you cannot see.
What “sent” means
WhooshBang reports what it can prove and nothing more. A provider returning a
successful response proves it accepted the message: Slack posted it, Telegram
created it, or the email provider took it for delivery. It does not prove a
device displayed it, or that a person read it. There is no delivered
state, and there will not be one invented for you.
| Machine state | Show your users | Means |
|---|---|---|
accepted, dispatch_pending | Accepted | WhooshBang durably owns the message |
queued, sending | Queued | Not yet accepted by the provider |
provider_accepted | Sent | The provider accepted it |
retry_wait | Retrying | A safe retry is scheduled |
terminal_failed | Couldn’t send | No further attempt will be made |
outcome_unknown | Outcome unknown | See below |
cancelled, expired | Cancelled or expired | Never reached the provider |
sending shares the “Queued” label on purpose. Until the provider accepts,
nothing has been sent, and an in-flight call is honestly still a message
waiting on the provider. The per-attempt history distinguishes them.
Machine states are stable and never translated. The labels are what you put in front of a human.
Diagnosing a failure
Every response carries a WhooshBang-Diagnostic-Id header, and every message
and problem document carries a diagnostic_id. Log it. It is the identifier
support can look up, and it is safe to put in your own logs and error reports
— it identifies the request, not its content.
A rejected request returns a Problem Details document; a message that failed
after acceptance carries the same shape as classified_error under
delivery. Either way the fields you read are the same:
{
"type": "https://whooshbang.flowxo.com/problems/subscriber_unbound",
"title": "Subscriber unbound",
"status": 422,
"detail": "No authorized channel binding is available for this subscriber and notifier.",
"code": "subscriber_unbound",
"diagnostic_id": "diag_subscriber_unbound_001",
"retryable": false
}
retryable is the field to branch on. It tells you whether another attempt
could plausibly succeed; code tells you what to do about it:
| Code | What it usually means |
|---|---|
subscriber_unbound | The subscriber never authorized, or revoked. Issue a new subscription link. |
environment_mismatch | A test credential reached for live, or the reverse — including a test credential writing project-wide recipient configuration, which is what live recipients see. |
request_invalid | The request does not satisfy the published contract. field_errors names each offending field as a JSON Pointer, with a code saying which kind of wrong it was — required_missing, unexpected_property, type_invalid, format_invalid, pattern_invalid, length_invalid, range_invalid, value_invalid. The value you sent is never echoed back. |
scope_forbidden | The credential lacks the scope for this operation — or, with a <gate>_gate field error, the organization is not yet on the invited-alpha allowlist. A session that names no organization gets this too; its detail says so, because every project belongs to a WhooshBang organization. |
idempotency_conflict | Same key, different payload. See idempotency. |
rate_limited | Slow down and retry after the indicated wait. |
provider_retryable | Transient; WhooshBang is already retrying if a retry is scheduled. |
provider_terminal | The provider refused permanently. Do not retry. |
provider_outcome_unknown | See unknown outcomes. |
internal_error | A fault inside WhooshBang, not in your provider or your own service. Retry; if it keeps failing, send support the diagnostic_id. |
Problem documents never disclose rate-limit internals or provider details, and
a request for a resource in another organization returns the same
resource_not_found as one that does not exist. That is deliberate: it means
an attacker holding a valid credential cannot use error differences to
discover what exists elsewhere.
The dashboard shows the same information as a per-attempt timeline, with the plain-language labels above, if you would rather look than parse.
Waiting on an invitation
WhooshBang is invite-only while it is in alpha. There is no endpoint that reports whether your organization has been invited, and there will not be one: the programme is temporary, and freezing our rollout-gate names into the V1 contract would leave you and us carrying them afterwards.
The refusal is the answer. A gated operation returns 403 scope_forbidden
with a field error whose code is the gate plus _gate —
external_sign_up_gate when creating a project, subscription_creation_gate
when issuing a subscription link, live_send_gate when sending to a real
recipient. The JS SDK reads it for you:
try {
await whooshBang.createMessage(request, { idempotencyKey: event.id });
} catch (error) {
if (error instanceof WhooshBangProblemError && error.pendingInvitation) {
// "live_send" — this organization has not been invited to this capability yet.
report(`Waiting on a WhooshBang invitation for ${error.pendingInvitation}.`);
}
}
Treat it as “not yet”, not as a permission bug: nothing about your credential or your code needs to change, and the same call succeeds once the invitation lands.
Unknown outcomes
Sometimes a provider call may have completed but produced no trustworthy result — a timeout after the request was already in flight, for example. WhooshBang records this honestly rather than guessing:
{
"id": "msg_synthetic_002",
"project_id": "project_synthetic",
"environment": "test",
"subscriber_id": "customer_456",
"notifier_id": "default",
"binding": {
"id": "binding_synthetic_002",
"project_id": "project_synthetic",
"environment": "test",
"subscriber_id": "customer_456",
"notifier_id": "default",
"channel": "telegram",
"status": "active"
},
"delivery_target": "simulator",
"state": "outcome_unknown",
"accepted_at": "2026-07-25T17:45:00Z",
"updated_at": "2026-07-25T17:45:03Z",
"expires_at": "2026-07-26T17:45:00Z",
"delivery": {
"attempt_count": 1,
"highest_proven_provider_state": "none",
"retry_scheduled": false,
"classified_error": {
"code": "provider_outcome_unknown",
"retryable": false,
"detail": "The provider may have accepted the request, but no trustworthy result is available.",
"diagnostic_id": "diag_provider_unknown_001"
}
},
"diagnostic_id": "diag_message_unknown_001",
"outcome_guidance": "Delivery may have been accepted by the provider. No automatic retry is scheduled; create a new message explicitly if another attempt is appropriate."
}
outcome_unknown is not a failure and not a success. No automatic retry is
scheduled, because retrying could put a second visible message in front of
the user. The decision is yours, and you make it by sending a new message
with a new idempotency key:
const current = await whooshBang.getMessage(message.id);
if (current.state === "outcome_unknown") {
// A new logical send, not a replay: reusing the original key would return
// the original message and change nothing.
const resend = await whooshBang.createMessage(request, {
idempotencyKey: `${originalKey}-resend-1`,
});
if (resend.outcome !== "accepted") {
// They can no longer be reached on this notifier; nothing was sent.
}
}
For a password reset, resending is usually right — a duplicate is cheap and a missing message is not. For “your payment succeeded”, a duplicate may be worse than silence. WhooshBang cannot make that call for you, so it does not.
The same reasoning applies to a transport failure on your side. If
createMessage throws WhooshBangOutcomeUnknownError, the send may have been
accepted. Read the message by its idempotency key — repeat the same request
with the same key, which returns the original if it exists — before deciding
to send anything new. A plain WhooshBangTransportError means nothing reached
the service, and a new attempt is safe.
Channel connections
A channel connection is one environment’s use of a provider identity — the bot a recipient actually sees. It belongs to exactly one environment, so test and live never share an identity, a subscriber, a binding, or a credential.
Three setup modes, and none of them falls back to another:
mode | Identity | Credential |
|---|---|---|
whooshbang_shared | A disclosed WhooshBang-owned bot | Ours. You cannot read, rotate, or retire it, and no reference to it appears anywhere in this API. |
customer_managed | A bot Telegram creates on your own Telegram account | Fetched server to server. It never passes through a browser, a clipboard, a request, or a response. |
customer_byok | A bot you already own | You send it once in credential. Write-only: sealed on arrival, never echoed by any later read, absent from every connection record. |
whooshbang_shared is a supported product option rather than a migration
step. It suits notifying yourself from an agent, an automation, or a webhook,
and it has no retirement gate. What it does not do is carry your brand, which
is what the other two are for.
An existing bot cannot become customer_managed. Telegram fixes a bot’s
manager when the bot is created, so a bot you already have can only be
connected with customer_byok.
Routing decides what actually sends
Creating a connection does not, by itself, make anything send through it. A notifier routes through exactly one connection at a time, and a connection nothing routes to is inert: it delivers nothing, and a subscriber cannot authorize against it either.
- The first connection in an environment whose default notifier has no route is attached automatically. There is nothing to displace, so the only thing that changes is a dead end.
- Every later connection lands unrouted. Promoting it is
attachNotifierConnection, and it demotes the previous default in the same transaction. - Detaching the default leaves the notifier with none, which stops its sends. Nothing is promoted in its place, because failing over would put an identity in front of subscribers who consented to a different one.
Subscribers who authorized a connection keep that consent when it stops being default; they stop being deliverable until it is default again. Consent is never moved between identities.
If sends are accepted and nothing arrives, read the routes first. An environment holding connections and no default route is the ordinary shape of that symptom.
Health, repair, and retirement
health is unknown, healthy, degraded, or unhealthy. degraded
still delivers on purpose — the identity a subscriber consented to has not
changed, only its last health check, and refusing to send would turn a
provider hiccup into a silent outage.
Archiving a connection is terminal. It stops that environment’s delivery and authorization and drops its routing. Reconnecting later is a new connection, so consent given to the archived identity is never revived under it. Archiving a customer-owned connection additionally removes the webhook at Telegram and destroys the stored credential, handing your bot back.
Retiring is yours for every mode, whooshbang_shared included. Retiring a
shared connection ends this environment’s use of the WhooshBang bot and
nothing else: the bindings that consented through it are invalidated exactly as
a customer-owned retirement invalidates its own, and the notifier stops routing
here. The bot itself is untouched — no credential retired, no webhook removed —
because every other environment that selected it is still sending through it,
and their subscribers consented to their own connection, never to yours.
What the shared identity does refuse is everything that would change WhooshBang’s own bot: rotating its credential, repairing its webhook, and purging it. Those are ours to run. Retirement is not one of them, and the refusals name only what they refuse.
Checking its health is not one of them either. health-check works on a shared
connection: WhooshBang runs the check against its own bot on your behalf and
records the result, so the identity you depend on has an answer rather than an
unknown you cannot resolve. The bot’s credential is never exposed by it. A
freshly selected shared connection is checked once at selection for the same
reason; if the provider cannot be reached, the connection is still created and
its health stays unknown rather than being reported unhealthy on the
strength of our own outage.
Isolation
The credential determines the organization, project, and environment. There is no parameter that widens that scope, and nothing you can pass to reach another tenant’s data.
A whooshbang_shared connection is isolated on exactly the same terms even
though the transport identity is WhooshBang’s. Two projects selecting the same
shared bot share no notifier, subscriber, binding, authorization token,
message, attempt, audit record, or rate limit. Provider chat identity alone is
never a tenant selector: inbound updates resolve an exact authorization or a
signed binding handle before any tenant state is touched, and an ambiguous
command fails closed rather than guessing.
A credential from one project used against another project’s resource returns
resource_not_found — the same response as a resource that does not exist. It
does not confirm that the resource exists elsewhere, and it does not tell you
whose it is. Do not treat resource_not_found as proof of absence when
debugging; check which credential you used first.
Content is encrypted at rest and retained only as long as the alpha retention window allows. Delivery metadata and diagnostics outlive it, so an old message can still explain what happened to it after its text is gone.
Message text never appears in WhooshBang logs or metrics, and neither does any Telegram username, user ID, or chat ID. If you need message content in your own observability, log it on your side before you send it.