API Keys & Programmatic Access
Drive form creation, cloning, and client intake links from your own backend with workspace-scoped API keys.
If you already run a CRM, case-management system, or in-house portal, you don’t have to click through the dashboard to create every form. API keys let your backend call the DS160.io v1 API directly — creating forms, cloning them, and generating client intake links on your schedule.
API keys are available on Business workspaces. Each key is tied to one workspace and can only act inside it.
API base URL
All v1 endpoints are rooted at:
https://ds160.io/api/v1
These docs may be hosted on your agency’s white-labelled domain, but API calls always go to ds160.io. Every code sample on this page uses the full URL so you can copy-paste without rewriting.
Endpoint reference
/v1 is stable — paths and field names won’t change underneath you without a /v2 major version (see Versioning and support). Paths in the table below are shown relative to the workspace prefix /workspaces/:workspaceId/; full request and response shapes are documented in each endpoint’s section.
| Method | Path | Scope | Request body |
|---|---|---|---|
POST | /forms | forms:write | name? |
POST | /forms/:formId/clone | forms:clone | disabledSections? |
POST | /forms/:formId/client-links | client-links:write | expiresInDays, defaultLanguage, hideBranding? |
GET | /forms | forms:read / forms:write | query: limit?, cursor? |
GET | /forms/:formId | forms:read / forms:write | — |
All bodies and responses are JSON. The forms:read scope grants read-only access to form metadata; forms:write grants both read and write, so a key that only ever writes still has read access for free.
1. Create an API key
Open Workspace Settings → API Keys and click Create API Key. Pick a descriptive name (we recommend one key per integration, e.g. Production CRM or Staging webhook handler), then choose the scopes the integration needs:
forms:read— read form metadata only (list / fetch)forms:write— create forms; also grants readforms:clone— duplicate an existing form into a new oneclient-links:write— generate client intake links
Only workspace Owners and Admins can mint keys.
When you click Create key, the platform shows you the full secret once. Copy it into your secrets manager immediately — after this modal closes, only the last four characters are ever displayed again. If you lose a key, revoke it and mint a new one.
2. Authenticate
All v1 endpoints expect an Authorization: Bearer <secret> header.
export DS160_KEY="...the secret you just copied..."
export DS160_WORKSPACE="your-workspace-id"
curl https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms \
-H "Authorization: Bearer $DS160_KEY" The workspace id appears in the URL of every dashboard page (/workspaces/<workspaceId>/...). The key’s workspace is bound on the server side — calling another workspace’s endpoint with the wrong key returns 403 Forbidden.
Tip: the language tabs sync across every snippet on this page. Pick your language once and the rest of the page follows.
3. Create a form
curl -X POST https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms \
-H "Authorization: Bearer $DS160_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Smith / B1 — 2026-05" }'
# → { "formId": "65f2…" } Each call consumes one form credit from your billing plan (same as creating a form from the dashboard). If your workspace is out of credits, the call returns 402 Payment Required — top up before retrying.
The optional name field sets a human-readable label for the form (up to 200 characters). Omit it to get an auto-generated name — either way, you can rename the form from the dashboard later.
4. Clone an existing form
If you have a template form you frequently re-issue (say, the same employer’s J-1 program), clone it instead of starting from scratch. Optionally pass disabledSections to omit specific form pages from the copy — anything you list is replaced with empty fields in the new form, so the client fills it in fresh:
curl -X POST https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms/$SOURCE_FORM_ID/clone \
-H "Authorization: Bearer $DS160_KEY" \
-H "Content-Type: application/json" \
-d '{ "disabledSections": ["spouse-info-page", "security-background-page-5"] }'
# → { "formId": "65f3…" } Cloning consumes one form credit just like creating a new form.
Valid disabledSections values
Pass an empty array (or omit the field entirely) to copy every page. Otherwise, use any combination of the identifiers below — anything else returns 400 Bad Request.
| Identifier | Page |
|---|---|
personal-info-page-1 | Personal Information - Part 1 |
personal-info-page-2 | Personal Information - Part 2 |
visa-purpose-page | Purpose of Visa |
travel-companions-page | Travel Companions |
previous-us-travel-page | Previous U.S. Travel History |
address-and-phone-page | Address and Phone Details |
passport-page | Passport Information |
contact-info-page | Contact Information |
family-info-page | Family Information |
spouse-info-page | Spouse Information |
deceased-spouse-info-page | Deceased Spouse Information |
former-spouse-info-page | Former Spouse Information |
present-occupation-page | Current Occupation |
previous-occuptation-page | Previous Occupation |
additional-occuptation-page | Additional Occupation Details |
security-background-page-1 | Security Background - Part 1 |
security-background-page-2 | Security Background - Part 2 |
security-background-page-3 | Security Background - Part 3 |
security-background-page-4 | Security Background - Part 4 |
security-background-page-5 | Security Background - Part 5 |
student-visa-page-1 | Student Visa Details - Part 1 |
student-visa-page-2 | Student Visa Details - Part 2 |
temporary-visa-page | Temporary Visa Information |
crew-visa-page | Crew Visa Information |
Only the sections relevant to the form’s visa category are actually present; listing a page that isn’t on the source form has no effect.
5. Generate a client intake link
Once a form exists, generate a tokenized URL the client can use to fill it in:
curl -X POST https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms/$FORM_ID/client-links \
-H "Authorization: Bearer $DS160_KEY" \
-H "Content-Type: application/json" \
-d '{ "expiresInDays": 7, "defaultLanguage": "en" }' Request body
| Field | Required | Notes |
|---|---|---|
expiresInDays | yes | Integer 1–365. No default — omitting it returns 400 Bad Request. The link’s expiresAt is computed as now + expiresInDays and the underlying token record is auto-deleted at that point. |
defaultLanguage | yes | One of the codes in Valid defaultLanguage values. Sets the locale prefix on the returned url and the language the intake form opens in. |
hideBranding | no | Boolean. When true, the intake form is shown unbranded — the agency’s whitelabel logo and theme are suppressed for this link. Defaults to false (the workspace’s whitelabel chrome is shown if configured). |
Example response (200 OK)
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI2NmMxYmQ0ZjZmNGFkNTQwMzMxNDhmYmEiLCJmb3JtSWQiOiI2NWYyYTkxMTNkZjAxYzAwMTI3YjY4MmEiLCJ3b3Jrc3BhY2VJZCI6IjY1ZjJhOTAwM2RmMDFjMDAxMjdiNjgwYSIsImV4cCI6MTc0ODA0ODI0NywiaWF0IjoxNzQ3NDQzNDQ3LCJkZWZhdWx0TGFuZ3VhZ2UiOiJlbiIsImhpZGVCcmFuZGluZyI6ZmFsc2V9.SiGNATuRe",
"url": "https://intake.your-agency.com/client-intake/65f2a9113df01c00127b682a?token=eyJhbGciOi…",
"expiresAt": "2026-05-24T01:50:46.000Z"
}
| Field | Notes |
|---|---|
token | The JWT also embedded in url. You normally don’t need it directly — clients open url and the server validates the embedded token. The token’s jti claim is the unique link ID; persist it in your system if you want to revoke this specific link later (see Revoking a client intake link). |
url | The shareable link. Uses your custom domain if one is verified and SSL-provisioned for the workspace; otherwise falls back to ds160.io. The locale path prefix (/es, /cn, …) is set based on defaultLanguage. |
expiresAt | ISO-8601 timestamp. The matching record is auto-deleted at that point, so links cannot be re-used after expiry. |
Send the url to your client. The token in the URL is single-purpose and expires on expiresAt — there’s no further auth required for the client to fill it in.
Revoking a client intake link
There is no v1 endpoint for revoking issued client links — revoke from the dashboard (Workspace → Form → Sharing → Revoke link). Revocation is immediate and permanent: the next request the link makes returns 401. If a leak is suspected and no human is available to revoke from the UI, the safest fallback is to let the link expire (cap expiresInDays accordingly).
Valid defaultLanguage values
defaultLanguage controls the language of the client intake UI when the recipient first opens the link. Use any of the codes below — anything else returns 400 Bad Request. The short codes (en, cn, …) are DS160.io’s internal locale identifiers; the BCP-47 column shows what we emit in <html lang>, hreflang, and Intl.* APIs. Use the short code in API requests; the BCP-47 form is informational.
| Code | Language | Native name | BCP-47 |
|---|---|---|---|
en | English | English | en-US |
ru | Russian | Русский | ru-RU |
ro | Romanian | Română | ro-RO |
es | Spanish | Español | es-ES |
cn | Chinese | 中文 | zh-CN |
vi | Vietnamese | Tiếng Việt | vi-VN |
hi | Hindi | हिन्दी | hi-IN |
nl | Dutch | Nederlands | nl-NL |
Treat client links like passwords
The url is a bearer credential — anyone holding it can fill in the form until it expires, with no second factor.
- Send over a trusted channel; don’t post in places that could leak (public chat, search-indexed pages, shared bundles).
- Redact
?token=in logs. Thejticlaim alone is safe to log. - Revoke on suspicion of a leak — revocation is immediate and permanent.
expiresInDaysis a tradeoff: shorter = smaller leak window, longer = less re-issuing friction.
6. List or fetch forms
# All forms in the workspace
curl https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms \
-H "Authorization: Bearer $DS160_KEY"
# A single form by id
curl https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms/$FORM_ID \
-H "Authorization: Bearer $DS160_KEY" These read endpoints accept the forms:read scope (read-only) or forms:write (which also grants read).
Pagination
GET /v1/workspaces/:workspaceId/forms is paginated. Pass ?limit= and ?cursor= query params to walk through results:
| Query param | Meaning |
|---|---|
limit | Max forms to return on this page. Default 50, hard cap 200. |
cursor | Form id from the previous page’s nextCursor. Omit on the first request to start from the newest form. |
Each response includes a nextCursor field. When there are no more pages, nextCursor is null. Example:
{
"forms": [
{ "id": "65f2a911…", "name": "Smith / B1", "status": "in_progress", "workspaceId": "65f2a900…", "userId": "65f2a8f0…", "preferredConsulate": null, "createdAt": "2026-05-14T09:12:33.000Z", "archivedAt": null },
{ "id": "65f2a8a3…", "name": "Garcia / F1", "status": "completed", "workspaceId": "65f2a900…", "userId": "65f2a8f0…", "preferredConsulate": "MAD", "createdAt": "2026-05-13T18:01:09.000Z", "archivedAt": null }
],
"nextCursor": "65f2a8a3…"
}
Walk forward by sending ?cursor=65f2a8a3…&limit=50 on the next request. Forms are sorted newest-first by id, so a cursor pins your read position even if new forms are created between calls — you won’t see duplicates and you won’t miss anything that existed at the time you started paginating.
Form status values
The status field on every form metadata response is one of:
| Value | When it applies |
|---|---|
not_started | Form was created (via API or dashboard) but no field has been answered yet. |
in_progress | At least one field has been answered. Most forms spend the bulk of their life in this state. |
completed | The applicant has finished and submitted the intake; the agency can now download/submit the DS-160 PDF. |
archived | The form was archived from the dashboard. Excluded from default list results unless you explicitly query for archived items. |
The wire format uses underscores (in_progress, not in-progress). Comparing against the strings above as-is will always work.
PII access via the API
The v1 form metadata endpoints (GET /workspaces/:workspaceId/forms and GET /workspaces/:workspaceId/forms/:formId) return only operational metadata: id, name, status, workspaceId, userId, preferredConsulate, createdAt, archivedAt. They do not expose the applicant’s answers — no names, DOBs, passport numbers, travel history, or any other DS-160 field is reachable through /v1.
Completed DS-160 PDFs and full field data are accessible only through the dashboard, under the applicant’s audit-logged owner. If your integration needs to read applicant-entered data, do that through your own client-intake flow (your CRM collects the data first, you push it into DS160.io) — never assume the API will hand it back.
Rate limits
Every key is limited to 60 requests per minute. Each response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The cap (60). |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix timestamp when the window resets. |
If you exceed the limit, the API returns 429 Too Many Requests with a Retry-After header. If your integration legitimately needs a higher ceiling, contact support — we can raise the limit for specific keys.
Idempotency and retries
The v1 API does not support an Idempotency-Key header. Concretely:
POST /formsandPOST /forms/:formId/cloneare not idempotent and consume a form credit on every call. A naive retry after a network timeout will create a duplicate form and burn a second credit.POST /forms/:formId/client-linksis not idempotent either but does not consume credits — a retried call simply mints a second link with its ownjtiandexpiresAt.- All
GETendpoints are safe to retry freely.
Recommended retry pattern for create/clone: if a POST /forms (or /clone) times out or returns a 5xx, do not retry blindly. Instead, call GET /workspaces/:workspaceId/forms (forms are sorted newest-first) and check whether a form with the name you supplied was created in the last few seconds. If it was, treat the original call as successful and skip the retry. If it wasn’t, the original write definitely didn’t land and a retry is safe.
We may add Idempotency-Key support in a future minor release; integrations should plan for it but not depend on it today.
Revoking a key
If a key is leaked, end-of-life, or simply unused, revoke it from Workspace Settings → API Keys → Revoke. Revocation takes effect immediately — the next request the key makes returns 401 Unauthorized. Revoked keys remain in the list for audit purposes but cannot be reactivated; mint a fresh one if you need to keep the integration running.
Best practices
- One key per integration. Easier to revoke surgically when a system is decommissioned, and the
Last usedcolumn in the dashboard tells you which integrations are still active. - Minimum scopes. A key that only generates client links shouldn’t have
forms:clone. Smaller scopes mean smaller blast radius if the secret leaks. - Store secrets in a vault. Never commit them to source control or embed them in a frontend bundle — every browser tab would expose them.
- Rotate on a schedule. Mint a new key, switch the integration over, then revoke the old one. The dashboard shows when a key was last used so you can confirm the cutover before revoking.
Errors
All non-2xx responses share the same JSON shape:
{ "error": "human-readable message" }
The error value is a string. For 400 Bad Request from schema validation, it’s a serialized JSON array describing each violated constraint; for everything else, it’s a short message you can surface or log directly.
| Status | When it happens | Example body |
|---|---|---|
400 | Request body or path params failed validation (missing required field, value out of range, unknown enum value, etc.). | { "error": "[{\"keyword\":\"required\",\"params\":{\"missingProperty\":\"expiresInDays\"}}]" } |
401 | Missing Authorization header, malformed Bearer token, the secret matches no key, or the key has been revoked. | { "error": "Missing API key" } · { "error": "Invalid API key" } · { "error": "API key revoked" } |
402 | The workspace is out of form credits. Create/clone only. Top up the workspace and retry. | { "error": "Workspace has no remaining credits" } |
403 | Key lacks the required scope, or the :workspaceId in the path doesn’t match the key’s bound workspace. | { "error": "Missing required scope: forms:write" } · { "error": "API key does not match workspace" } |
404 | Form not found, or the form exists but lives in a different workspace than the key. (Returned as 404 rather than 403 to avoid leaking existence across workspaces.) | { "error": "Form not found" } |
429 | Per-key rate limit exceeded (default 60 req/min). Back off until the Retry-After value (seconds) and retry. | { "error": "Rate limit exceeded" } |
5xx | Transient server error. Safe to retry GETs freely; for write endpoints, see Idempotency and retries before retrying. | — |
End-to-end example
A complete intake flow — create a form, mint a client link, send it, and poll for completion — using curl. Real integrations will use their own HTTP client; the shape is the same in any language.
# 0. credentials (set once)
export DS160_KEY="..."
export DS160_WORKSPACE="65f2a900..."
# 1. Create a form. Capture the formId from the response.
FORM_ID=$(curl -s -X POST \
https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms \
-H "Authorization: Bearer $DS160_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Smith / B1 — 2026-05" }' \
| jq -r .formId)
# 2. Mint a client intake link valid for 14 days.
LINK_JSON=$(curl -s -X POST \
https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms/$FORM_ID/client-links \
-H "Authorization: Bearer $DS160_KEY" \
-H "Content-Type: application/json" \
-d '{ "expiresInDays": 14, "defaultLanguage": "en" }')
CLIENT_URL=$(echo "$LINK_JSON" | jq -r .url)
# 3. Send $CLIENT_URL to the applicant through your usual channel
# (email, secure portal, etc.). The applicant fills in the form.
# 4. Poll for completion. Status transitions:
# not_started → in_progress → completed
curl -s https://ds160.io/api/v1/workspaces/$DS160_WORKSPACE/forms/$FORM_ID \
-H "Authorization: Bearer $DS160_KEY" \
| jq .status
# → "completed" when the applicant has finished
# 5. Download the DS-160 PDF from the dashboard
# (no v1 endpoint for this today).
A few notes on the recipe:
- Polling cadence: every 5–15 minutes is plenty. With a 60 req/min ceiling, a single key can comfortably poll thousands of in-flight forms.
- Webhooks are not available yet. If you need push notifications on completion, watch this page or contact support — it’s on the roadmap.
- Step 5 (downloading the completed PDF) requires a dashboard session today. The v1 API intentionally does not expose applicant answers — see PII access via the API.
Versioning and support
Stability. /v1 is the stable API surface. We add new optional fields, new endpoints, and new enum values within /v1 without bumping the major version, but we do not rename, remove, or change the type of any existing field. If we ever need a breaking change, it ships under /v2 and /v1 keeps working for at least 12 months alongside it.
Backward-compatible additions you should plan for:
- New optional request fields will appear over time. Existing requests that don’t send them continue to work.
- New response fields may be added. Treat unknown fields as ignorable — don’t fail closed if a future field appears.
- New enum values for
status,defaultLanguage, anddisabledSectionswill be added. Don’t crash on unknown values; log and fall through.
Support:
- Higher rate limits, custom domains, webhook early-access, and integration questions: contact your account manager or support@ds160.io.
- Bug reports against the v1 API: same address. Every response carries an
x-request-idheader — include it with your report so we can find the exact call in our logs.