Delegated OTP delivery
Some customers need the code to leave through their own channel rather than ours: their recipients only accept mail from their domains, or the notice has to come from the system that already talks to those people. The case that motivated this channel is a promissory note, where the code that signs the document has to reach the end customer through the customer's own channel.
The delegated channel covers exactly that, and nothing else. The only thing delegated is the transport.
What stays with us
Read this table first, because it is the difference between a delivery channel and a verification channel.
| Part of the OTP | Who does it |
|---|---|
| Generating the code | AltScore |
| Expiration and maximum attempts | AltScore |
| Delivering the code to the person | The customer, at their endpoint |
| Asking for the code and verifying it | AltScore, on the same screen as always |
| Signing the authorization and its evidence | AltScore |
The person types the code into our screen, exactly as they would with WhatsApp, SMS, or email. That is why the authorization ends up with hasSignatures: true and its full signature evidence — including the otpVerifiedFrom context described in OTP Verification — and that is what makes it usable for a promissory note. If the customer both delivered the code and verified it on their side, there would be no signature of ours to show.
How to set it up
Three steps, in this order. Each one depends on the previous.
1. Create the connector
In Connectors, create a new connector with the Delegated OTP delivery channel. It asks for three things:
| Field | What it is |
|---|---|
| Name | How you recognize it in the connectors grid |
| Delivery URL | The customer endpoint that receives the POST. Required, and https: anything else is rejected on save and on send |
| Secret | The credential agreed with the customer, under one of the two keys below |
The secret is stored under one of two keys, and this channel reads only those two:
| Secret key | Header it travels in |
|---|---|
otp_delivery_token | Authorization: Bearer <secret> |
otp_delivery_api_key | X-Api-Key: <secret> |
The form presents them as a choice — Bearer token or API key — and switching from one to the other discards the one left behind, so a connector ends up with a single secret. The restriction that matters is the list itself: no other connector secret is ever sent to this endpoint. The WhatsApp access_token lives in the same secrets dictionary and never leaves through here.
A delegated delivery connector does not handle conversations: it has no default responder, no support hours, and no keywords. It is an address with a credential.
2. Create the OTP template
In Settings → OTP Templates, create a template with the Delivered by the customer channel and pick the connector from the previous step. Without a connector the template cannot send anything, and the send fails with OTP_TEMPLATE_WITHOUT_CONNECTOR.
The template also sets the language and the slug that travel in the POST, and the code length.
3. Point the node at that template
In the flow, the OTP Verification node picks the Delivered by the customer channel and then the template. Nothing else changes: contact source, attempts, expiration, and backup methods all behave as they do on any other channel.
Unlike every other channel, this one accepts both phone and email as the contact source. The customer decides how to reach the person, so the destination can be either.
The contract with the customer endpoint
This is what the customer has to implement. The whole block can be copied and handed to their team.
A POST to the delivery URL, with Content-Type: application/json:
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
Idempotency-Key | The same value as the idempotencyKey field |
Authorization or X-Api-Key | The configured secret, depending on which key was used |
What each field means
| Field | What it is |
|---|---|
otpId | The identifier of the one-time code. It is the same on the primary send and on every backup |
code | The bare code, with no text around it |
target | Who to deliver it to: a phone number or an email address |
contactMethod | email when target contains an at sign, phone otherwise. Derived from the value, not from how the node was configured |
templateSlug | The OTP template's slug. It defaults to tenant:templateId, but can be set to whatever the customer prefers for routing on their side |
language | The template's language |
message | The message already composed from the template's copy, in case they want to use it as is |
idempotencyKey | The otpId and the send number, colon-separated: 0 is the primary send, 1 onward are the backups |
message can be ignored entirely. A customer who wants their own wording composes it from code and never looks at the rest.
The response may carry a JSON identifier of its own (messageId, message_id, id, or reference); it is read, but this channel registers no delivery tracking, so it does not lead anywhere yet.
The rules, and why they exist
Only a 2xx counts as accepted. A 301 does not. If the customer moves the path and leaves a redirect behind, without this rule we would count every code as sent while nothing left their side — and with the send mode set to cascade, that phantom success would suppress the backup channel that would have worked.
The Idempotency-Key must be honored. It exists so that a retry of ours does not reach the same person twice. Two POSTs carrying the same key are the same send.
There is a 12-second wall-clock ceiling to respond. Someone is waiting in front of a screen on the other side; each network operation also has its own 8-second timeout. Past the ceiling we cut the call and the send counts as failed, which — with backups configured — beats waiting longer.
The URL must be https and cannot point at a private network or loopback. The connector is tenant configuration and the service POSTs to it from its own egress, so a URL pointing inward would turn the connector into a way to reach whatever the service can reach. localhost, metadata endpoints, and private, loopback, link-local, and reserved addresses are all refused.
When it fails
This channel's errors carry an errorSubCode saying what to check. None of them include the customer endpoint's response body: that body can quote the code we just sent, and these messages end up in a conversation notice and in unauthenticated responses.
errorSubCode | What happened |
|---|---|
OTP_TEMPLATE_WITHOUT_CONNECTOR | The template uses this channel but has no connector configured |
OTP_CONNECTOR_NOT_FOUND | The connector the template points at no longer exists |
OTP_CONNECTOR_WRONG_CHANNEL | The template points at a connector of another channel |
OTP_DELEGATED_WITHOUT_URL | The connector has no delivery URL |
OTP_DELEGATED_ENDPOINT_NOT_ALLOWED | The URL is not https, or it points at a private network or loopback |
OTP_DELEGATED_UNREACHABLE | The endpoint could not be reached, or the time ceiling ran out |
OTP_DELEGATED_REJECTED | The endpoint answered something other than 2xx. The status code comes back in providerStatus |
In the flow, a failed send leaves through the OTP node's error handle, the same as on any other channel.
Limitations
There is no delivery confirmation. A 2xx means "the customer received it", never "it reached the person". No provider reports back to us afterwards, which is why this channel registers no delivery legs: there would be nothing to resolve later, and a leg sitting at "accepted" forever would be a "we do not know" pretending to be tracking. When knowing matters, configure a backup method on another channel and the code goes out through both.
The message body copy. The message field is built by joining three template texts: preOtpBlurb (the code runs straight onto the end of it), posOtpBlurb, and didNotAskMessage, each as its own paragraph, with the empty ones skipped. A template with none of the three sends the bare code, which is a perfectly usable message for a customer who composes their own text. Editing this copy from the application is in progress; until it lands, a freshly created template on this channel delivers the code alone.
The seller portal does not offer this channel. The seller portal's OTP login accepts WhatsApp, SMS, and email; delegated delivery is, for now, used only from conversational flows.
Things to Keep in Mind
- What is delegated is the transport, not the verification. If the customer also wants to verify the code on their side, this channel is not what they are after: the authorization's signature comes from the person typing the code into our screen.
- Only two secret keys are sent.
otp_delivery_tokenandotp_delivery_api_key. Any other connector credential stays where it is. - A 2xx is not a delivery. With a backup method on another channel and the send mode set to cascade, the backup is only attempted if the endpoint rejected the code synchronously.
- The endpoint has to be fast. Twelve seconds is the ceiling, and a person is waiting for the code on the other side.
- The URL is https or it does not save. And it cannot point inside a private network.
Customer SMTP server
Send email out through the customer's own SMTP server instead of a managed inbox — the fields, the Microsoft 365 requirements, and what you give up.
Customer Portal
The browser-based application where customers interact with your conversational flows — in chat or wizard mode, with no login required.