SMS verification
The SMS second factor end to end — the fields, the two gate modes, what the invitation and the page show before the code, the signer's side, the monthly allowance, and the events you receive.
A signer can be required to enter a one-time code we text to their mobile phone. It binds the signature to a phone: the signature is an advanced electronic signature (AES) instead of the simple (SES) email-link level. This page is the whole contract; the endpoints link here.
In one paragraph: you name the signer's email and mobile number, we send the
invitation email, and the signer asks for the code on the signing page — we
never text a code on our own. sms_gate decides whether that happens before
the document is shown or at the Sign press. Every response reads the factor
back, a call your monthly SMS allowance cannot cover is refused up front, and
six webhook events tell you what happened to each code.
The fields
The same three fields on every create path:
| Field | Value | Where |
|---|---|---|
phone_e164 | The mobile number in E.164: +, then 8–15 digits, no spaces (^\+[1-9]\d{7,14}$), e.g. +41791112233. | signers[] on POST /v1/signing-requests, recipients[] on instantiate and confirm. |
require_sms_verification | true | same |
sms_gate | "before_sign" (default) or "before_view" — when the code is asked. | same |
Rules, identical on the three endpoints:
require_sms_verification: truewithoutphone_e164is refused before anything is created:400 invalid_request, messagephone_e164 is required when require_sms_verification is true. On confirm, the rule is checked on the row as edited (a phone stored at instantiate counts) and fails as422 invalid_recipients.sms_gate: "before_view"withoutrequire_sms_verification: trueis refused the same way (sms_gate "before_view" requires require_sms_verification: true).- A
phone_e164without the flag is stored, but no code is ever asked for. - Only the shape of the number is checked when you create the request. Whether it is a mobile, a landline or unreachable shows when the signer asks for a code — see When no SMS arrives.
- Signer and recipient objects are strict: a misspelt key such as
phoneis a400 unknown_signer_field/unknown_recipient_field, never dropped. On confirm,phoneis still accepted as a deprecated alias ofphone_e164until 2026-12-31 (each use adds adeprecated_fieldwarning; both with different values are a400 conflicting_fields). - The code SMS follows the signer's
locale— see The SMS itself. Oninstantiatethere is onelocalefor all recipients.
No invitation SMS on these paths. POST /v1/signing-requests and a direct
instantiate invite by email only; the SMS is the code, requested by the
signer on the signing page. That is also why send_emails: false never stops
the code: you distribute the link, the signer opens it and asks for the code
at the gate. (The one invitation SMS is channel: "sms" or "both" on a
confirm, which texts the signing link
itself.)
Availability and the monthly allowance
SMS verification is available on every plan, and the codes are included in it: there is no price per SMS. Each workspace has a monthly allowance of SMS codes, which bounds abuse and cost:
| Plan | Codes included per month |
|---|---|
| Free | 25 |
| Pro | 50 |
| Branded | 200 |
| Teams, Enterprise | 100 per paid seat |
If your firm needs more, we raise your monthly allowance. Email support@wesign.now with your workspace ID and the number of codes you expect per month. The raise is included in your subscription, is added to what your plan includes, and applies every month until it is changed.
- Every code sent counts once, including a resend. A send that is refused (no usable number, a landline, a blocked number, the allowance used up) does not count.
- The counter resets at 00:00 UTC on the 1st of each month. A raise is not reset: it is part of your allowance, not a one-off credit.
GET /v1/mereturns the current state assms_allowance: { monthly, used, remaining, resets_at }.monthlyis your total, the plan's codes plus any raise, and it is the number every check below and thesms_allowanceof the SMS webhooks use.- Checked up front.
POST /v1/signing-requests, a directPOST /v1/templates/{id}/instantiateandPOST /v1/documents/{id}/confirmcount the signers who must verify by SMS. If they outnumber the codes left, the call answers402and nothing is created or sent:
{
"error": "This request has 2 signers who must verify by SMS, but the workspace has 1 of 300 SMS codes left this month (resets 2026-10-01T00:00:00.000Z). Nothing was created or sent.",
"code": "sms_allowance_exhausted",
"sms_allowance": { "monthly": 300, "used": 299, "remaining": 1, "resets_at": "2026-10-01T00:00:00.000Z" },
"required": 2
}- A
validate_onlydry run ofinstantiatereports the same shortfall asproblems: [{ "code": "sms_allowance_exhausted", … }]withok: falseand HTTP 200. Areview: trueinstance is checked when it is confirmed. - The check guarantees one code per SMS signer. A signer who asks for a second code uses another one, so the allowance can still run out between the create call and the signing.
- Never downgraded. When the allowance runs out after the request was
created, a signer who asks for a code is refused, the
signing_request.sms_failedwebhook fires withreason: "allowance_exhausted", and the signer cannot sign until the counter resets. A request is never turned into a signature without SMS.
When the code is asked: sms_gate
before_sign (default)
The signer opens the link and sees the whole document at once. The code is requested when they press Sign, after every other step has passed — signature fields placed, consent ticked, signature captured. The SMS protects the signature, not the reading: whoever holds the link can read the document.
before_view
The signer sees nothing of the document until the code is verified: no title, no file name, no pages, no prefilled values. The code is texted when the signer taps Text me a code on that page — never on page load, so a mail scanner that opens the link in a browser sends no SMS and uses up none of your allowance. After a reload in the same browser tab within 10 minutes the page shows the code field again instead of texting another code; the signer can always tap "Send a new code". After the code is verified the page reloads into the normal signing page, and Sign does not ask again. Use it when the document itself carries personal data — a name, a date of birth, an address, a social security number — and must not be readable by whoever holds the link.
// POST /v1/signing-requests — one signer, SMS before the document is shown
{
"signers": [{
"role": "client", "email": "anna@example.ch", "name": "Anna Muster",
"locale": "de",
"phone_e164": "+41791112233",
"require_sms_verification": true,
"sms_gate": "before_view",
"reference": "crm-contact-4711"
}]
}Until the code is verified:
- The signing page's own document endpoints (
/api/sign/{token}/pdfand/api/sign/{token}/fields, used by the page — not part of the public API) answer403 sms_verification_requiredbefore anything about the file — its name included — leaves the server. The page never emits the page images. - Every invitation email for the request leaves the document unnamed: the first invite, reminders (manual and automatic) and the hand-over to a sequential signer.
- The invitation SMS of a confirm with
channelsmsorbothreads "… asks you to sign a document." instead of naming the file. signing_request.viewedfires only after the code.- A signer who is logged in to an account with the same email address sees the request in their "awaiting your signature" list and notifications as "Document", without its name (logging in is an email code — the same factor as the link).
Dashboard envelopes (several documents to one signer): an unverified
before_view request is also shown when another request of the same envelope,
for the same signer email and the same phone, is already verified. That
opens it for viewing only — the Sign press still asks for that request's own
code. A different phone never borrows a verification.
Embedded signing does not support before_view yet. A frame minted for
such a request shows the "session not available" card and posts
wesign.error with detail: "sms_required". Use the hosted signing link for
these signers, or before_sign inside the frame.
What the invitation email shows
For an API send, {sender} is the workspace name (never a person: an API
key has no human sender). {document} is the stored file name: from
POST /v1/signing-requests the filename field, else the uploaded file's
name or the name at the end of file_url; from a template, the template name
plus .pdf. Either way letters and digits of every script are kept, as are
_, . and -; spaces and any other character become _
(Vollmacht Müller → Vollmacht_Müller.pdf).
| Part | before_sign | before_view |
|---|---|---|
| From | Branded, Teams, Enterprise: "{workspace}" <noreply@send.letssign.now>. Free, Pro: "{workspace}, wesign" <noreply@send.letssign.now>. | same |
| Reply-To | The workspace's reply-to address (Settings → Branding, Branded and up), otherwise support@wesign.now. | same |
| Subject | {sender} asks for your signature on “{document}” (all six languages below) | Document to sign from {sender} |
| Inbox preview | {sender} requested your signature on "{document}" | {sender} sent you a document to sign. You'll receive an access code by SMS when you open the link. |
| Header | Branded and up: the workspace logo on the brand colour (the wesign.now wordmark when no logo is set). Free, Pro: the wesign.now wordmark. The pill "SIGNATURE REQUESTED". | same |
| Heading | You have a document to sign. | same |
| Body | {sender} has asked you to review and sign "{document}". | {sender} has sent you a document to sign. To protect its contents, it opens only after you enter a code we send you by SMS. |
| Document row | {document} | Protected — opens after an SMS code |
| Your role | The template slot's role label, on instantiate and confirm when the template names one. Not on POST /v1/signing-requests. | same |
| From row | The sender name, plus the workspace's reply-to address when one is set. | same |
| Button and link | "Review and sign", plus the full link as text — on the email host: https://{slug}.wesign.now/{locale}/sign/… (Branded and up), otherwise https://www.wesign.now/{locale}/sign/…. The same token as the API's signingUrl, which is on letssign.now; both open the same page. | same |
| Expiry | This link expires on {date}. | same |
| Footer | "Do not share this email", the legal-evidence line, "Delivered via / Sent by wesign.now"; sender of record: the workspace name and postal address (Branded and up, when the address is set), otherwise NC Labs GmbH, Zurich; "not a marketing message". | same |
| Never | Attachments, the phone number, the code, the signer's name, a personal message (the API has no message field). | same |
Subjects in every language the email is sent in (the signer's locale):
locale | before_sign | before_view |
|---|---|---|
en | {sender} asks for your signature on “{document}” | Document to sign from {sender} |
de | {sender} bittet um Ihre Unterschrift: {document} | Dokument zur Unterschrift von {sender} |
fr | {sender} demande votre signature sur « {document} » | Document à signer de la part de {sender} |
it | {sender} chiede la tua firma su «{document}» | Documento da firmare da parte di {sender} |
es | {sender} pide tu firma en "{document}" | Documento para firmar de {sender} |
nl | {sender} vraagt om je handtekening op "{document}" | Document om te ondertekenen van {sender} |
The plain-text part is derived from the HTML and follows the same rules.
If the request cannot be read at the moment an invitation or reminder is sent
(a database hiccup on our side), the email plays safe both ways: the subject is
Document to sign from {sender}, the body says only that {sender} sent a
document to sign, there is no document row, and no SMS code is announced.
There is no custom sending domain on any plan, Enterprise included: the
address is always noreply@send.letssign.now and only the display name
changes. Replies go to Reply-To, never to the noreply address.
The completion email a signer receives once every party has signed carries the signed PDF. The audit certificate is attached for signers only when the workspace's audit-trail setting includes them (the default sends it to the sender only).
What the signing page shows before the code
before_sign | before_view | |
|---|---|---|
Browser tab and link preview (og:title) | Sign {file name without .pdf} — from {workspace} | Sign document — from {workspace} |
| Brand header | The workspace brand (Branded and up), otherwise the platform's | same |
| Document (pages, PDF) | Shown | Not sent to the browser |
| Prefilled values, fields | Shown | Not sent |
| Signer's name and email | Shown | Not sent |
| "Invited by", other signers, quote tables | Shown | Not sent |
| Decline button | Shown | Not shown |
| Code step | At the Sign press: Verify your phone, the masked phone, a 6-digit field. | The only thing on the page: From {workspace}, A document is waiting for your signature, We'll text a code to •••• •••• 1234. Enter it here to open the document., a Text me a code button, Codes expire after 10 minutes. After the tap: Enter the code we sent to •••• •••• 1234 to open it., a 6-digit field, Continue, Send a new code. |
The wording above is the English page. Search engines are told not to index either page.
With before_view and no phone on file, the page says the document is
protected and asks the signer to contact the sender. An expired, queued,
withdrawn or declined request shows its usual notice, which never names the
document.
The SMS itself
The code is sent through Twilio Verify, not as a message we compose.
- Language. The signing request's
locale(en,de,fr,it,es,nl) is passed to Twilio, so a French-speaking signer with a Swiss number gets French. Should Twilio refuse the language (Twilio: 404, retry without it), the code is sent once more without it and Twilio picks the language from the number's country — German for every +41 number, English for +31 and +423 (Twilio's default languages). That country rule is what every code followed before the 2026-09-24 release. - Wording. Twilio's template, not ours. Its
documented English default
is
Your {service name} verification code is: {code}; Twilio states the wording may change. The service name is our platform's Twilio setting, the same for every workspace. Your workspace name and the document are never in the code SMS: we send Twilio the number, the channel and the language, nothing else. - Your workspace name in the SMS: planned. It needs Twilio's per-request company name, which Twilio enables per account on request; no date until it is enabled.
- Sender ID. Twilio sends Verify codes from its own shared senders. Which number or name a handset shows, per country, is not documented by Twilio.
- RCS. Twilio may deliver a code over RCS instead of SMS where the handset supports it (on by default at Twilio).
The signer's side
| Code | 6 digits. |
| Lifetime | 10 minutes (Twilio Verify's default; the page says so). After that: "Code expired — request a new one". |
| Resend | "Send a new code" on the page. Within the 10 minutes Twilio re-sends the same code. Each send counts against the allowance and fires sms_sent (attempt counts them). |
| Wrong code | "Wrong code"; the signer tries again. Recorded on the audit trail (sms_verify_failed, meta.reason: "wrong-code"); no webhook. |
| Attempts | Twilio allows 5 checks per code. After that the signer reads "Too many wrong codes. Wait 10 minutes, then request a new code." — the code stays locked until it expires, 10 minutes after it was sent. |
| Too many sends | More than 5 sends without a successful check are refused by Twilio until the code expires (sms_failed with reason: "rate_limited"). |
| Our own throttle | Per IP address: 4 code requests and 8 checks per minute. Refused clicks emit no event. |
| After the code | The verification is recorded on the request (sms_verified_at, webhook sms_verified) and holds for that request from any browser that has the link. It is part of the audit trail and of the audit certificate. |
| Not signable | A withdrawn, declined, queued, signed or expired request gets no code. |
Every message above is shown in English whatever the signer's language today.
When no SMS arrives
Every refused send fires signing_request.sms_failed
the moment the signer asks for a code — with before_view that is the moment
they tap "Text me a code" on the page, with before_sign the moment they press
Sign.
reason | Cause | What the signer sees | What you do |
|---|---|---|---|
invalid_number | No usable E.164 number is stored, or Twilio rejected the number (Twilio 60200). | "No verifiable phone on file", or "Phone number not deliverable" | Correct the number. |
landline | Twilio identified a landline (Twilio 60205; see below). | "Phone number not deliverable" | Correct it to a mobile number. |
blocked | The destination country is not enabled for our SMS service (60605), is embargoed (60238), or Twilio's fraud protection blocked the number's prefix for 12 hours (60410). | "Phone number not deliverable" | Contact support with the signing_request_id. |
rate_limited | More than 5 codes for this number without a completed check (60203), or Twilio throttling (60212, 20429). | "Try again in a moment" | Wait; the first case clears when the 10-minute code expires. |
allowance_exhausted | The workspace's monthly allowance is used up. | "Monthly SMS limit reached for this workspace" | Nothing until the reset on the 1st; the signer cannot sign before. |
not_configured | The deployment has no SMS provider. Never in production. | "SMS not configured on this deployment" | — |
provider_error | Anything else from Twilio. | Twilio's error message | Retry later; contact support if it persists. |
The messages in the third column are shown in English on every locale today.
- Landlines. Twilio refuses a landline at send time only when line-type
lookup is enabled on its Verify service; for every other case Twilio does
not document what happens, so a code to a landline can also be accepted
(
sms_sent) and simply never arrive. A line-type check when you create the request (so a landline is a400before anything is sent) is planned for 2026-10-31. - Fraud protection. Twilio's fraud guard is on for every Verify account and
can also block legitimate sends — Twilio names a first send to a country the
account has never texted before as an example. It shows as
blocked. - Sent but not received.
signing_request.sms_sentmeans Twilio accepted the code, not that the handset received it. A signer who has asms_sentbut nosms_verifiedafter a while has most likely not received it: check the number (phone_masked) and let them tap "Send a new code".
Changing the phone number
PATCH https://api.wesign.now/v1/signing-requests/{id}
Authorization: Bearer wsk_live_…
Content-Type: application/json
{ "phone_e164": "+41791234567" }- Allowed while the request is
pending,viewedorqueuedand the signer has not verified a code yet. Otherwise409 not_editablewithmeta.statusandmeta.sms_verified_at— once verified, the number is part of the evidence for that request. To reach the signer on another number after that, withdraw the request and create a new one. - Changes the phone and nothing else: the flag, the gate, the link and the
status stay. Nothing is sent — no email, no SMS: the signer requests the
code on the page, which now goes to the new number. A code already sent to
the old number cannot verify the new one. No webhook fires; the next
sms_sentcarries the newphone_masked. - The body is strict:
400 unknown_fieldfor any other key (meta.fields,meta.accepted),400 invalid_phonefor a number that is not E.164,400 invalid_requestfor a missingphone_e164or a body that is not JSON.nullis not accepted — removing the number would lock the signer out. The same number again answers200without a change. - Writes the audit event
phone_changed(meta.fromandmeta.to, both masked) and answers theGET /v1/signing-requests/{id}shape. Same auth, workspace scope and rate limit as the GET; another workspace's id is a404.
Before a staged template instance is confirmed, the phone can also be changed in the confirm body.
The signer reports a wrong number but the request is already verified, signed or withdrawn? Then there is nothing to correct on it: create a new request.
Reading it back
Every signer object in the create response, both instantiate responses, the
confirm response and GET /v1/signing-requests/{id} carries:
| Field | Meaning |
|---|---|
require_sms_verification | Whether the signer must enter a code. |
phone_masked | The stored phone, last four digits only (•••• •••• 7037); null without a phone. The full number is never returned. |
sms_verified_at | When the signer entered the correct code; null until then. |
sms_gate | before_sign or before_view. |
signature_level | AES when require_sms_verification is true, else SES — the level the request was created for. |
reference | Your own reference for this signer, or null. |
company, job_title | The company and function you stated for the signer, or null. |
Check require_sms_verification and signature_level on the create
response, not on your own request body: that is the proof the factor
arrived. GET /v1/documents/{id} does not carry the SMS fields.
Events
| Webhook | When | Extra fields |
|---|---|---|
signing_request.sms_sent | Twilio accepted a code. One per code, resends included. | phone_masked, attempt, sms_allowance |
signing_request.sms_failed | A code could not be sent. | reason, phone_masked, sms_allowance |
signing_request.sms_verified | The signer entered the correct code. Once. | phone_masked, sms_verified_at |
Payloads, the envelope and the full reason list are on
Webhooks.
The audit trail (auditEvents on GET /v1/signing-requests/{id}) records
sms_verify_sent (meta.masked_phone, meta.quota_used, meta.quota_cap),
sms_verify_failed (meta.phase: send or check, meta.reason — our
internal code, e.g. wrong-code, landline, quota-exceeded),
sms_verify_ok, and phone_changed for a PATCH. The invitation SMS of a
confirm with channel sms or both is recorded as sms_sent /
sms_failed, with the full number in meta.to.
Countries
Numbers are checked for their shape only; we keep no country list in the API.
Which destination countries are enabled is a setting of our Twilio Verify
service; a number in a country that is not enabled fails with sms_failed
reason: "blocked" when the code is requested. Ask
support for the current list before you rely on a country.
Twilio's own sender rules, for the countries our customers ask about most (Twilio's SMS guidelines; how they apply to Verify's shared senders is not documented by Twilio):
| Country | Alphanumeric sender without registration | Registration |
|---|---|---|
| Switzerland, Liechtenstein, France, Italy | supported | not required |
| Germany | supported | not applicable |
| Austria | supported until 2026-10-01 | required from 2026-10-01 |
| Spain | not supported | required |
Not offered
Verifying a number without a signing request is not offered and not
planned (the 2026-11-15 date once listed on this page is withdrawn). A code
is only ever requested by the signer, on their own signing link. Check
numbers in your own interface before you create a request; afterwards
signing_request.sms_failed names an unusable number and
PATCH /v1/signing-requests/{id}
corrects it.
Not available yet
- A line-type check at create: planned for 2026-10-31.
- A sandbox with simulated email and SMS visible through the API: planned for 2026-11-30.
- Your workspace name in the SMS: planned, date depends on Twilio.
