Appearance
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
POST /api/shield/handoffwith the prompt and files. You get back a prompt file and protected attachments.- Put those files into a new chat with the vendor's assistant.
POST /api/shield/handoff/restorewith 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
| Field | Type | Required | Description |
|---|---|---|---|
prompt | string | yes | What the assistant should do. Up to 200,000 characters. |
files | array | no | Up to 10 { documentType, base64 }, 13 MB in total (decoded). All files travel in one request. |
files[].documentType | string | yes | txt, md, pdf, docx, csv, xlsx or json. |
projectId | string | no | Share 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"
}handoffFileis 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 asresultFileName.Files are renamed
attachment-N.<ext>, because a file name can itself contain personal data. Each keeps its format:.docxand.xlsxare rebuilt with the stand-ins in place (mediaTypeis the Office type),.csv,.txt,.mdand.jsonkeep their extension, a PDF becomes Markdown. A.docx/.xlsxwhose values could not all be replaced inside the file is sent asattachment-N.mdtext withextraction.keptAsText: true, never as the original.Word and Excel files are read completely (headers, footers, footnotes, text boxes, every sheet).
extractionreports 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
extractioncounts what was left out (nullfor 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 withUNREADABLE_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
.docxfiles are removed and counted (imagesRemoved) unless a company admin allowed thumbnails (Shield → Settings → Pictures in the Protected Handoff, orPUT /api/shield/sensitivitywith{"handoffImagePolicy": "THUMBNAIL"};STRIPis the default). Thenimageslists 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.
sourceIndexis the attachment it came from;extraction.imagesThumbnailedcounts 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.
| Status | Code | When |
|---|---|---|
| 400 | EMPTY_PROMPT, TOO_MANY_FILES, BAD_FILE | Invalid input; fileIndex names the file. |
| 400 | BAD_RETENTION | retention is not DEFAULT, KEEP or NO_LIMIT. |
| 403 | NO_LIMIT_NOT_ALLOWED | retention: "NO_LIMIT", but your company does not allow handoffs without a time limit. Use KEEP. |
| 403 | FEATURE_NOT_IN_PACKAGE, PROJECT_NOT_ACCESSIBLE | Plan or project. |
| 413 | TOO_LARGE | Prompt over 200,000 characters, or files over 13 MB in total. Split the files across handoffs. |
| 409 | SCOPE_ROTATED | Your administrator gave this scope new stand-ins while the handoff was being saved. Nothing is created; try again. |
| 409 | STAND_IN_COLLISION | Another handoff of yours was created at the same moment and took one of these stand-ins. Nothing is created; try again. |
| 413 | TOO_MANY_STAND_INS | You hold more than 200,000 live stand-ins in your company. Delete older handoffs or let them expire, then try again. Nothing is created. |
| 422 | UNREADABLE_FILE | A file has no readable text (for example a scanned PDF). Nothing is created. |
| 409 | SURROGATE_KEY_CHANGED | The 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. |
| 502 | SANITIZER_FAILED | The protection service did not answer. Nothing is created; retry. |
| 503 | SURROGATE_KEY_UNAVAILABLE | The 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
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | The 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.
| Field | Meaning |
|---|---|
verdict.status | clean: 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 / distinct | Occurrences and distinct stand-ins swapped back. |
expiredOrDeleted | Your stand-ins whose handoff has expired. They stay as they are. |
unrecognised | Codes 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:
429witherror,usedandlimit.
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.
| Status | Code | When |
|---|---|---|
| 400 | BAD_RETENTION | Not one of the three values. |
| 403 | NO_LIMIT_NOT_ALLOWED | Your company does not allow handoffs without a time limit. |
| 404 | NOT_FOUND | Not yours, or already expired. |
| 409 | KEEP_LIMIT_REACHED | Keeping 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"
}| Field | Meaning |
|---|---|
kind, label | user (their email) or project (its name); label is null when the user or project is gone. |
activeGeneration | How many times the scope has had a fresh start, plus one. |
keyId | The protection service key the current stand-ins are made with; null until something was protected in the scope. |
liveEntries | Stand-ins of the current generation that have not expired. |
olderLiveEntries | Stand-ins from before the last fresh start that have not expired; they still restore. |
keyIds, activeKeyId | The 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 keyk1, 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.
| Code | Status | When |
|---|---|---|
BAD_ROTATION_REQUEST | 400 | Not exactly one of scopeId and keyId, or not a valid key id. |
SCOPE_NOT_FOUND | 404 | No such scope in your company. |
SALT_ROTATION_CONFLICT | 409 | Someone else renewed this scope just now. Nothing changed; reload and try again. |
KEY_STILL_ACTIVE | 409 | That key is still the one new stand-ins are made with. Your operator makes another key active first. |
SURROGATE_KEY_UNAVAILABLE | 503 | The 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 }| Field | Meaning |
|---|---|
status | none: no key yet (the other fields are null). unconfirmed: created, but its last characters were not re-entered yet. confirmed: saved and confirmed. |
keyId | The key's public name, rk and 8 characters. It is not secret and opens nothing. |
createdAt, confirmedAt | When 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 getRESTORE_KEY_CHANGEDand 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
keyIdonly.
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.
| Code | HTTP status | When |
|---|---|---|
BAD_RESTORE_KEY_REQUEST | 400 Bad Request | The 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_MISMATCH | 400 Bad Request | These are not the last 8 characters of the current key. Check the saved key and try again. |
RESTORE_KEY_NOT_FOUND | 404 Not Found | Your company has no restore key yet. Create one first. |
RESTORE_KEY_EXISTS | 409 Conflict | Your company already has a key. Send "replace": true if it was lost. |
RESTORE_KEY_CHANGED | 409 Conflict | The key you meant to replace or confirm is no longer the current one: someone replaced it first. Read the status again. |
RESTORE_KEY_CONFIRM_LOCKED | 429 Too Many Requests | Five wrong attempts within 15 minutes. Wait, or replace the key if it was not saved. |
RESTORE_KEY_FAILED | 500 Internal Server Error | Something 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.
