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:

FieldValueWhere
phone_e164The 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_verificationtruesame
sms_gate"before_sign" (default) or "before_view" — when the code is asked.same

Rules, identical on the three endpoints:

  • require_sms_verification: true without phone_e164 is refused before anything is created: 400 invalid_request, message phone_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 as 422 invalid_recipients.
  • sms_gate: "before_view" without require_sms_verification: true is refused the same way (sms_gate "before_view" requires require_sms_verification: true).
  • A phone_e164 without 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 phone is a 400 unknown_signer_field / unknown_recipient_field, never dropped. On confirm, phone is still accepted as a deprecated alias of phone_e164 until 2026-12-31 (each use adds a deprecated_field warning; both with different values are a 400 conflicting_fields).
  • The code SMS follows the signer's locale — see The SMS itself. On instantiate there is one locale for 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:

PlanCodes included per month
Free25
Pro50
Branded200
Teams, Enterprise100 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/me returns the current state as sms_allowance: { monthly, used, remaining, resets_at }. monthly is your total, the plan's codes plus any raise, and it is the number every check below and the sms_allowance of the SMS webhooks use.
  • Checked up front. POST /v1/signing-requests, a direct POST /v1/templates/{id}/instantiate and POST /v1/documents/{id}/confirm count the signers who must verify by SMS. If they outnumber the codes left, the call answers 402 and 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_only dry run of instantiate reports the same shortfall as problems: [{ "code": "sms_allowance_exhausted", … }] with ok: false and HTTP 200. A review: true instance 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_failed webhook fires with reason: "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}/pdf and /api/sign/{token}/fields, used by the page — not part of the public API) answer 403 sms_verification_required before 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 channel sms or both reads "… asks you to sign a document." instead of naming the file.
  • signing_request.viewed fires 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).

Partbefore_signbefore_view
FromBranded, Teams, Enterprise: "{workspace}" <noreply@send.letssign.now>. Free, Pro: "{workspace}, wesign" <noreply@send.letssign.now>.same
Reply-ToThe 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.
HeaderBranded 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
HeadingYou 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 roleThe template slot's role label, on instantiate and confirm when the template names one. Not on POST /v1/signing-requests.same
From rowThe 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
ExpiryThis 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
NeverAttachments, 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):

localebefore_signbefore_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_signbefore_view
Browser tab and link preview (og:title)Sign {file name without .pdf} — from {workspace}Sign document — from {workspace}
Brand headerThe workspace brand (Branded and up), otherwise the platform'ssame
Document (pages, PDF)ShownNot sent to the browser
Prefilled values, fieldsShownNot sent
Signer's name and emailShownNot sent
"Invited by", other signers, quote tablesShownNot sent
Decline buttonShownNot shown
Code stepAt 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

Code6 digits.
Lifetime10 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.
AttemptsTwilio 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 sendsMore than 5 sends without a successful check are refused by Twilio until the code expires (sms_failed with reason: "rate_limited").
Our own throttlePer IP address: 4 code requests and 8 checks per minute. Refused clicks emit no event.
After the codeThe 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 signableA 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.

reasonCauseWhat the signer seesWhat you do
invalid_numberNo 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.
landlineTwilio identified a landline (Twilio 60205; see below)."Phone number not deliverable"Correct it to a mobile number.
blockedThe 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_limitedMore 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_exhaustedThe 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_configuredThe deployment has no SMS provider. Never in production."SMS not configured on this deployment"—
provider_errorAnything else from Twilio.Twilio's error messageRetry 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 a 400 before 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_sent means Twilio accepted the code, not that the handset received it. A signer who has a sms_sent but no sms_verified after 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, viewed or queued and the signer has not verified a code yet. Otherwise 409 not_editable with meta.status and meta.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_sent carries the new phone_masked.
  • The body is strict: 400 unknown_field for any other key (meta.fields, meta.accepted), 400 invalid_phone for a number that is not E.164, 400 invalid_request for a missing phone_e164 or a body that is not JSON. null is not accepted — removing the number would lock the signer out. The same number again answers 200 without a change.
  • Writes the audit event phone_changed (meta.from and meta.to, both masked) and answers the GET /v1/signing-requests/{id} shape. Same auth, workspace scope and rate limit as the GET; another workspace's id is a 404.

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:

FieldMeaning
require_sms_verificationWhether the signer must enter a code.
phone_maskedThe stored phone, last four digits only (•••• •••• 7037); null without a phone. The full number is never returned.
sms_verified_atWhen the signer entered the correct code; null until then.
sms_gatebefore_sign or before_view.
signature_levelAES when require_sms_verification is true, else SES — the level the request was created for.
referenceYour own reference for this signer, or null.
company, job_titleThe 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

WebhookWhenExtra fields
signing_request.sms_sentTwilio accepted a code. One per code, resends included.phone_masked, attempt, sms_allowance
signing_request.sms_failedA code could not be sent.reason, phone_masked, sms_allowance
signing_request.sms_verifiedThe 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):

CountryAlphanumeric sender without registrationRegistration
Switzerland, Liechtenstein, France, Italysupportednot required
Germanysupportednot applicable
Austriasupported until 2026-10-01required from 2026-10-01
Spainnot supportedrequired

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.