Skip to content

Shield Protected Handoff API ​

Protect a prompt and its documents so you can carry them into a vendor chat (Claude, ChatGPT, Gemini, Copilot), then restore the chat's answer. This is the API behind Shield → Protected handoff; the hands-on guide is Protected Handoff.

  • Base URL: https://<your-veriprompt-host>
  • Authentication: a signed-in session, or a Shield API key: Authorization: Bearer vp-gw_sk_…
  • Package: needs Shield data protection in your plan; otherwise 403 FEATURE_NOT_IN_PACKAGE.
  • Ownership: every handoff belongs to the user who created it. Nobody else, including colleagues and company admins, can list, restore or delete it; for anyone else an id behaves as if it did not exist.

Workflow ​

  1. POST /api/shield/handoff with the prompt and files. You get back a prompt file and protected attachments.
  2. Put those files into a new chat with the vendor's assistant.
  3. POST /api/shield/handoff/restore with the answer. You get the answer back with the original values.
bash
# 1. Protect
curl -s https://<host>/api/shield/handoff \
  -H "Authorization: Bearer YOUR_SHIELD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Summarise the contract for Anna Schmidt and draft a reply to anna@acme.de.",
    "files": [{ "documentType": "docx", "base64": "'"$(base64 -w0 contract.docx)"'" }]
  }' > handoff.json

# 2. Write the files out and carry them into the chat
jq -r '.handoffFile.base64' handoff.json | base64 -d > "$(jq -r '.handoffFile.name' handoff.json)"
jq -c '.files[]' handoff.json | while read -r f; do
  echo "$f" | jq -r '.base64' | base64 -d > "$(echo "$f" | jq -r '.name')"
done

# 3. Restore the answer you copied from the chat
curl -s https://<host>/api/shield/handoff/restore \
  -H "Authorization: Bearer YOUR_SHIELD_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile t answer.md '{text: $t}')"

POST /api/shield/handoff ​

Protect a prompt and up to 10 files.

Request body ​

FieldTypeRequiredDescription
promptstringyesWhat the assistant should do. Up to 200,000 characters.
filesarraynoUp to 10 { documentType, base64 }, 13 MB in total (decoded). All files travel in one request.
files[].documentTypestringyestxt, md, pdf, docx, csv, xlsx or json.
projectIdstringnoShare stand-ins across a project you can access. Without it, stand-ins are scoped to you.

Response 201 ​

json
{
  "handoffId": "ckx…",
  "expiresAt": "2026-10-10T09:00:00.000Z",
  "handoffFile": { "name": "prompt-1a2b3c4d.md", "mediaType": "text/markdown", "base64": "…" },
  "files": [{ "name": "attachment-1.docx",
              "mediaType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "base64": "…",
              "extraction": { "pageCount": 3, "hiddenTextRemoved": 47, "imagesRemoved": 2,
                              "pagesWithoutText": [3], "ocrPages": [], "activeContentRemoved": 1, "metadataRemoved": 2 } }],
  "images": [],
  "protectedCounts": { "PERSON": 1, "EMAIL_ADDRESS": 1 },
  "resultFileName": "result-1a2b3c4d.md"
}
  • handoffFile is the file to put into the chat first. It carries the protective instructions, a note on the stand-ins, and the request to answer in the chat and as resultFileName.

  • Files are renamed attachment-N.<ext>, because a file name can itself contain personal data. Each keeps its format: .docx and .xlsx are rebuilt with the stand-ins in place (mediaType is the Office type), .csv, .txt, .md and .json keep their extension, a PDF becomes Markdown. A .docx/.xlsx whose values could not all be replaced inside the file is sent as attachment-N.md text with extraction.keptAsText: true, never as the original.

  • Word and Excel files are read completely (headers, footers, footnotes, text boxes, every sheet). extraction reports what did not travel: hiddenTextRemoved (Word-hidden text), revisionsRemoved (comments and tracked changes; deleted text is dropped, insertions kept), formulasReplaced (Excel formulas replaced by their values), hiddenPartsShown (hidden sheets, rows and columns made visible), imagesRemoved, activeContentRemoved (embedded objects, macros), metadataRemoved (author, company, title).

  • PDFs go through the Shield extraction standard: the attachment is Markdown (headings, lists, tables), and extraction counts what was left out (null for text formats):

    • hiddenTextRemoved: characters a reader would not see (invisible, white, under 1.5 pt, off the page). They are removed and replaced by [Hidden text removed], because instructions aimed at an AI model hide there.
    • imagesRemoved: pictures, replaced by [Image removed].
    • pagesWithoutText: pages that are scans; they are left out with a marker. A PDF with no text layer at all is refused with UNREADABLE_FILE.
    • ocrPages: scanned pages that carry an OCR layer (invisible text over the scan image). That text is what the page says, so it travels, protected like any text, under the marker [Scanned page: this text comes from its OCR layer and was not checked against the image]. Nothing compares it with the picture: check it before you send it. Invisible text on an ordinary page is still removed.
    • activeContentRemoved: links, form fields, embedded files and JavaScript; metadataRemoved: author, title and similar properties. Neither is carried, because the attachment is text.
  • Pictures inside PDFs and .docx files are removed and counted (imagesRemoved) unless a company admin allowed thumbnails (Shield → Settings → Pictures in the Protected Handoff, or PUT /api/shield/sensitivity with {"handoffImagePolicy": "THUMBNAIL"}; STRIP is the default). Then images lists each picture as a re-encoded thumbnail, and the attachment's text says [Image: attachment-1-image-1.jpg] where it was:

    json
    "images": [{ "name": "attachment-1-image-1.jpg", "mediaType": "image/jpeg", "base64": "…",
                 "sourceIndex": 0, "width": 512, "height": 384 }]

    A thumbnail is a new JPEG (PNG for transparency) from the pixels only: at most 512 px and 300 KB, no EXIF, GPS, colour profile or trailing data; at most 20 per handoff. sourceIndex is the attachment it came from; extraction.imagesThumbnailed counts them. What a picture shows is not checked, so pass on only the ones you mean to. Never thumbnailed: scanned and OCR pages, icons under 24 px, pictures off the page, Excel pictures.

  • Personal data is replaced with stand-ins that look real but carry a short code: Jon Doe-665gf1, jon.doe-665gf1@example.com, 14.05.2026-ab12cd. The same value gets the same stand-in across your handoffs in the same scope.

  • Detection uses your company's saved Shield profile and sensitivity setting, and the column-aware strategy for CSV and XLSX.

Errors ​

Every error has error (a message) and, where listed, code.

StatusCodeWhen
400EMPTY_PROMPT, TOO_MANY_FILES, BAD_FILEInvalid input; fileIndex names the file.
400BAD_RETENTIONretention is not DEFAULT, KEEP or NO_LIMIT.
403NO_LIMIT_NOT_ALLOWEDretention: "NO_LIMIT", but your company does not allow handoffs without a time limit. Use KEEP.
403FEATURE_NOT_IN_PACKAGE, PROJECT_NOT_ACCESSIBLEPlan or project.
413TOO_LARGEPrompt over 200,000 characters, or files over 13 MB in total. Split the files across handoffs.
409SCOPE_ROTATEDYour administrator gave this scope new stand-ins while the handoff was being saved. Nothing is created; try again.
409STAND_IN_COLLISIONAnother handoff of yours was created at the same moment and took one of these stand-ins. Nothing is created; try again.
413TOO_MANY_STAND_INSYou hold more than 200,000 live stand-ins in your company. Delete older handoffs or let them expire, then try again. Nothing is created.
422UNREADABLE_FILEA file has no readable text (for example a scanned PDF). Nothing is created.
409SURROGATE_KEY_CHANGEDThe protection service's stand-in key changed for this scope, so new stand-ins would not match your earlier ones. Nothing is created; ask your administrator.
502SANITIZER_FAILEDThe protection service did not answer. Nothing is created; retry.
503SURROGATE_KEY_UNAVAILABLEThe protection service does not have the stand-in key this scope uses, or is not ready for it yet (for example during an update, or until your administrator has confirmed the key after an upgrade). Nothing is created; try again later or ask your administrator.

POST /api/shield/handoff/restore ​

Swap your stand-ins in an answer back to the original values.

Request body ​

FieldTypeRequiredDescription
textstringyesThe answer from the chat, including its first line, or the text of the returned .md. Up to 2,000,000 characters.

Response 200 ​

json
{
  "text": "Summary for Anna Schmidt: …",
  "verdict": { "status": "clean", "reason": null },
  "replaced": 2,
  "distinct": 2,
  "expiredOrDeleted": 0,
  "unrecognised": 0,
  "highlights": [
    { "start": 12, "end": 24, "kind": "restored", "entityType": "PERSON" },
    { "start": 36, "end": 46, "kind": "restored", "entityType": "DATE" }
  ]
}

highlights marks the restored text: each value swapped back (restored), each stand-in left as is because its handoff expired (expired), and each code that looks like a stand-in but matched nothing (unrecognised, which unrecognised counts). start and end are UTF-16 offsets into text (end exclusive, as JavaScript's slice), in order and never overlapping; entityType is set for restored and expired.

FieldMeaning
verdict.statusclean: the assistant reported no hidden instructions. withheld: it refused, reason says why. none: the security check line is missing. malformed: the line is garbled. Treat anything but clean with care.
replaced / distinctOccurrences and distinct stand-ins swapped back.
expiredOrDeletedYour stand-ins whose handoff has expired. They stay as they are.
unrecognisedCodes that look like stand-ins but match nothing, for example ones the chat changed or from a deleted handoff.
  • No handoff id is needed: restore finds your stand-ins in the text, exactly as written.
  • A restore keeps the handoffs it used alive for another 7 days.
  • A restore counts as a Shield request toward your monthly limit (10 on the free tier). Over the limit: 429 with error, used and limit.

GET /api/shield/handoff ​

Your live handoffs, newest first, at most 100. No content and no file names are stored, so none are returned.

json
{ "handoffs": [{ "id": "ckx…", "createdAt": "…", "expiresAt": "…", "lastRestoredAt": null,
  "entryCount": 3, "fileCount": 1, "retention": "DEFAULT" }] }

DELETE /api/shield/handoff/:id ​

Delete a handoff now: its original values are destroyed. A value that also appears in another of your live handoffs can still be restored from that one. 200 { "deleted": true }, or 404 if the id does not exist or is not yours.

PATCH /api/shield/handoff/:id ​

Change how long a handoff lives: { "retention": "KEEP" } keeps it until you delete it (at most your company's maximum after creation), "DEFAULT" lets it expire again when unused, "NO_LIMIT" removes the limit where your company allows it. Returns the handoff as in the list above.

StatusCodeWhen
400BAD_RETENTIONNot one of the three values.
403NO_LIMIT_NOT_ALLOWEDYour company does not allow handoffs without a time limit.
404NOT_FOUNDNot yours, or already expired.
409KEEP_LIMIT_REACHEDKeeping it would end it sooner than now (it is close to your company's maximum, or past it); it stays as it was.

You can also choose when protecting: add "retention": "KEEP" to the POST body.

Expiry ​

A handoff expires 7 days after you create it or last restore from it, unless you keep it or your admin changed the days (1 to 30). A kept handoff lives until you delete it, at most your company's maximum (1 year unless changed). Expired handoffs are deleted automatically within the hour. Handoffs of people who left the company are deleted 30 days after they left, unless an admin holds them. The rules: GET /api/shield/handoff/settings returns { "defaultDays": 7, "keepMaxDays": 365, "noLimitAllowed": false }.


Stand-in keys (company admins) ​

Company admins can give a user's or a project's stand-ins a fresh start: new handoffs in that scope then get new stand-ins. Use it when you suspect a leak, for example after a person left with copies of old chats. These two endpoints are for company admins only (a signed-in session, not an API key), work on your own company only, and need no particular plan. The page for them is Shield → Settings → Manage protected handoffs → Stand-in keys; the guide is New stand-ins for a scope.

Stand-ins already handed out keep working until their handoff expires: a rotation does not recall what was already shared. No response ever contains a secret, a value or a stand-in.

GET /api/shield/handoff/scopes ​

json
{
  "scopes": [
    { "scopeId": "ssc_7f3a91c2", "kind": "user", "label": "anna.schmidt@acme.example",
      "activeGeneration": 2, "keyId": "k2", "provenance": "ROTATED",
      "rotatedAt": "2026-10-05T14:02:11.000Z", "liveEntries": 4, "olderLiveEntries": 11 },
    { "scopeId": "ssc_0b55e4d8", "kind": "project", "label": "Contract review",
      "activeGeneration": 1, "keyId": "k1", "provenance": "MINTED",
      "rotatedAt": null, "liveEntries": 27, "olderLiveEntries": 0 }
  ],
  "keyIds": ["k1", "k2"],
  "activeKeyId": "k2"
}
FieldMeaning
kind, labeluser (their email) or project (its name); label is null when the user or project is gone.
activeGenerationHow many times the scope has had a fresh start, plus one.
keyIdThe protection service key the current stand-ins are made with; null until something was protected in the scope.
liveEntriesStand-ins of the current generation that have not expired.
olderLiveEntriesStand-ins from before the last fresh start that have not expired; they still restore.
keyIds, activeKeyIdThe keys in use, and the one new stand-ins are made with (null when the protection service cannot be reached).

POST /api/shield/handoff/scopes/rotate ​

Send exactly one of:

  • { "scopeId": "ssc_7f3a91c2" }: one scope. Returns { "scopeId": "ssc_7f3a91c2", "generation": 3, "surrogateKeyId": "k2" }.
  • { "keyId": "k1" }: every scope of your company whose current stand-ins use key k1, for a suspected leak of that key. Returns { "rotated": 14, "keyId": "k1", "activeKeyId": "k2" }. A scope someone else renewed at the same moment is skipped.
bash
curl -s https://<host>/api/shield/handoff/scopes/rotate \
  -H "Cookie: <your signed-in session>" \
  -H "Content-Type: application/json" \
  -d '{"scopeId":"ssc_7f3a91c2"}'

Every rotation is recorded in the audit log.

CodeStatusWhen
BAD_ROTATION_REQUEST400Not exactly one of scopeId and keyId, or not a valid key id.
SCOPE_NOT_FOUND404No such scope in your company.
SALT_ROTATION_CONFLICT409Someone else renewed this scope just now. Nothing changed; reload and try again.
KEY_STILL_ACTIVE409That key is still the one new stand-ins are made with. Your operator makes another key active first.
SURROGATE_KEY_UNAVAILABLE503The protection service has no readable key right now. Nothing changed; try again later.

Restore key (company admins) ​

The restore key is your company's way back after it leaves VeriPrompt: if your company comes back after its data was archived, the archive opens only with this key. VeriPrompt never stores the key and nobody can recover it, VeriPrompt included. Archiving itself arrives with a later release; until then you can create and confirm the key, so it is ready.

These three endpoints are for company admins only (a signed-in session, not an API key), work on your own company only, and need no particular plan. The page for them is Shield → Settings → Manage protected handoffs → Restore key; the guide is Your restore key. Every response is sent with Cache-Control: no-store. All values below are made up.

GET /api/shield/handoff/restore-key ​

json
{ "status": "unconfirmed", "keyId": "rk3f9a0c2e", "createdAt": "2026-10-06T09:12:40.000Z", "confirmedAt": null }
FieldMeaning
statusnone: no key yet (the other fields are null). unconfirmed: created, but its last characters were not re-entered yet. confirmed: saved and confirmed.
keyIdThe key's public name, rk and 8 characters. It is not secret and opens nothing.
createdAt, confirmedAtWhen the current key was created and confirmed.

The key itself is never returned here.

POST /api/shield/handoff/restore-key ​

Creates the key. The response is the only time you ever see it. Save it at once, outside VeriPrompt (for example in your password manager).

bash
curl -s https://<host>/api/shield/handoff/restore-key \
  -H "Cookie: <your signed-in session>" \
  -H "Content-Type: application/json" \
  -d '{}'
json
{ "key": "vprk1-rk3f9a0c2e-7QKD-M2XA-9WTB-H4RC-PE6N-1YVJ-ZS8F-K3MQ-D7XB-A2WN-T9HC-5RVE-Q4KG", "keyId": "rk3f9a0c2e" }
  • If your company already has a key, this is refused with RESTORE_KEY_EXISTS. To make a new one, send { "replace": true, "expectedKeyId": "<the keyId you are replacing>" }. If someone else replaced the key in the meantime, you get RESTORE_KEY_CHANGED and nothing changes: read the status again before you decide. The old key then stops being the current one; anything already sealed with it still needs the old key, so keep the old one as long as that matters.
  • Creating and replacing are recorded in the audit log, with the key's keyId only.

POST /api/shield/handoff/restore-key/confirm ​

Proves the key was saved: send its last 8 characters. Dashes, spaces and upper or lower case do not matter. Add expectedKeyId (the key id you are looking at) so that, if another admin replaced the key in the meantime, you get RESTORE_KEY_CHANGED instead of a wrong attempt against the new key.

bash
curl -s https://<host>/api/shield/handoff/restore-key/confirm \
  -H "Cookie: <your signed-in session>" \
  -H "Content-Type: application/json" \
  -d '{"suffix":"5RVE-Q4KG","expectedKeyId":"rk3f9a0c2e"}'

Returns the status as in GET, now confirmed. Confirming again with the right characters changes nothing and answers the same.

CodeHTTP statusWhen
BAD_RESTORE_KEY_REQUEST400 Bad RequestThe body is not valid JSON, replace is not true or false, or suffix is not 8 characters of the key alphabet (this does not count as a wrong attempt).
RESTORE_KEY_SUFFIX_MISMATCH400 Bad RequestThese are not the last 8 characters of the current key. Check the saved key and try again.
RESTORE_KEY_NOT_FOUND404 Not FoundYour company has no restore key yet. Create one first.
RESTORE_KEY_EXISTS409 ConflictYour company already has a key. Send "replace": true if it was lost.
RESTORE_KEY_CHANGED409 ConflictThe key you meant to replace or confirm is no longer the current one: someone replaced it first. Read the status again.
RESTORE_KEY_CONFIRM_LOCKED429 Too Many RequestsFive wrong attempts within 15 minutes. Wait, or replace the key if it was not saved.
RESTORE_KEY_FAILED500 Internal Server ErrorSomething went wrong. If you were creating a key, read the status first: one may have been created.

Without a session you get 401 with UNAUTHORIZED; a user without a company gets 400 with NO_COMPANY; without a company admin role, 403 with FORBIDDEN. Every answer is sent with Cache-Control: no-store.