Creates a new case file in Penneo with the specified documents and signers. This endpoint accepts a multipart/form-data request containing JSON metadata and files.
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Open API 3.0 Specs
The Open API 3.0 Specs are available on the following urls:
Case File Data Structure Documentation
This document describes the complete JSON structure for the data field when creating a Penneo case file.
Table of Contents
- Overview
- Root Structure
- Case File Object
- Signers Array
- Documents Array
- CC Recipients Array [Optional]
- Minimal Example
- Extended Example
Overview
The data field must be a stringified JSON object containing a caseFile property. This structure defines all aspects of your case file including title, signers, documents, and various configuration options.
Format: The data must be sent as a JSON string, not as a JSON object.
Root Structure
{
"caseFile": {
// Case file configuration (see below)
}
}Properties
| Property | Type | Required | Description |
|---|---|---|---|
caseFile | object | Yes | The main case file configuration object |
Case File Object
The caseFile object contains all configuration for the case file.
Required Properties
| Property | Type | Description | Constraints |
|---|---|---|---|
title | string | The case file title displayed in Penneo | Min: 1 char, Max: 1024 chars |
signers | array | Array of signer objects | At least 1 signer required |
documents | array | Array of document objects | At least 1 signable document required |
Optional CaseFile properties
| Property | Type | Default | Description |
|---|---|---|---|
activateCaseFile | boolean | true | When true, the case file is activated/sent and signing links are returned. When false, the case file is left unsent and signingLinks is []. |
ccRecipients | array | - | Recipients who receive a copy but don't sign |
language | string | null | null | Case file language. Applied to all signers without a specific language. Options: 'en', 'da', 'sv', 'nl', 'fr', 'fi', 'no', 'de', or null |
metaData | string | - | Metadata searchable in Penneo |
reference | string | - | Reference shown in Penneo web application |
folderId | integer | - | Folder ID where case file should be placed (min: 1) |
sendAt | integer | null | null | Unix timestamp when case file should be sent. null = immediately |
expireAt | integer | null | null | Unix timestamp when case file will expire |
sensitiveData | boolean | false | If true, signers must validate their identity before accessing documents |
visibilityMode | integer | 0 | Document visibility: 0 = all docs visible, 1 = only docs signer must sign |
disableNotificationsOwner | boolean | false | If true, case file owner won't receive notifications |
disableEmailAttachments | boolean | false | Disables email attachments in finalization emails (overridden if sensitiveData is true) |
senderName | string | null | null | Custom sender display name for outgoing emails (2–255 chars). Leave empty or null to use the default. |
signOnMeeting | boolean | false | Enable signing documents on a meeting |
Signers Array
Each signer represents a person who will sign or review the documents.
Required Properties
| Property | Type | Description | Constraints |
|---|---|---|---|
name | string | Name of the signer | Min: 1 character |
Optional Properties
| Property | Type | Description | Constraints |
|---|---|---|---|
email | string | Signer's email address. Used for email delivery. Unused when channel is eboks | Valid email format |
channel | string | Delivery channel. Defaults to email when omitted. eboks delivers via e-Boks | 'email' or 'eboks' |
cpr | string | Exactly 10-digit CPR number for e-Boks. Mutually exclusive with cvr. Only valid when channel is eboks | Exactly 10 digits. Required (without cvr) when channel is eboks |
cvr | string | 8-digit CVR for e-Boks, optionally with an alphanumeric :suffix. Mutually exclusive with cpr. Only valid when channel is eboks | 8 digits, optional alphanumeric :suffix. Required (without cpr) when channel is eboks |
socialSecurityNumberPlain | string | null | Signer's SSN or phone number | E.164 format recommended for phone |
vatin | string | null | VAT identification number of signer's company | - |
role | string | Role identifier for the signer. If no role is provided, "Signer" will be used as the default role for all signers | Min: 3 chars. Any characters allowed. Must not start or end with whitespace. |
onBehalfOf | string | Company/organization that this signer signs on behalf of | - |
signOrder | integer | Signing order (0 = can sign immediately) | Min: 0 |
ssnType | string | Type of SSN/mobile identification | 'legacy', 'dk:cpr', 'se:pin', 'no:nin', 'be:nrn', 'fi:pic', 'sms' |
accessControl | boolean | If enabled, SSN/VATIN/phone is validated before access | - |
successUrl | string | Redirect URL after successful signing | Valid URI, min: 5 chars |
failUrl | string | Redirect URL after signing failure | Valid URI, min: 5 chars |
language | string | null | Signer's preferred language | 'en', 'da', 'sv', 'nl', 'fr', 'fi', 'no', 'de', or null |
emailFormat | string | Email format | 'text' or 'html' |
emailSubject | string | Custom email subject | Min: 5 chars |
emailText | string | Custom email body text | Min: 5 chars |
reminderInterval | integer | Days before reminder is sent | Min: 1 |
reminderEmailSubject | string | Custom reminder email subject | Min: 5 chars |
reminderEmailText | string | Custom reminder email body | Min: 5 chars |
completedEmailSubject | string | Custom completion email subject | Min: 5 chars |
completedEmailText | string | Custom completion email body | Min: 5 chars |
activeAt | integer | null | Unix timestamp when signer becomes active | |
expireAt | integer | null | Unix timestamp when signer access expires | |
enableInsecureSigning | boolean | Enable insecure signing methods | |
insecureSigningMethods | array | If insecure signing methods are enabled, choose which ones are available for the signer | Array of allowed insecure signing methods: 'text', 'draw', 'image' |
secureSigningMethods | array | The allowed secure electronic signing methods per signer. Choose only the ones that are enabled for the customer | Array of: 'mitid.dk', 'bankid_se', 'bankid_no', 'itsme.be', 'ftn.fi', 'sms', 'eid.belgium.be', 'passport-reader.eident.dk', 'ausweisapp.bund.de', 'qes.personal.mitid.dk', 'qes.erhverv.mitid.dk' |
storeAsContact | boolean | - | If true, signer will be stored as contact in Penneo |
Documents Array
Each document represents a file to be signed or attached to the case file.
Required Properties
| Property | Type | Description | Constraints |
|---|---|---|---|
title | string | Document title | Min: 1 char, Max: 1024 chars |
name | string | File name (must match uploaded file) | Must end with .pdf or .zip. No : " / \ * ? < > | ; = # % or control characters. Min 5, max 255 chars. |
Optional Properties
| Property | Type | Default | Description | Constraints |
|---|---|---|---|---|
signable | boolean | true | If true, creates signable document. If false, document is a non-signable attachment | - |
roles | array | - | Array of role identifiers that can sign this document | Each role: min 3 chars. Any characters allowed. Must not start or end with whitespace. |
documentOrder | integer | - | Display order of the document | Min: 0 |
metaData | string | - | Metadata searchable in Penneo | - |
PDF and ZIP documents
Each document can be a PDF or a ZIP archive. PDFs and ZIPs can be mixed in the same case file, and a case file can contain several ZIP documents.
- A ZIP archive is added as one document. Its files are not extracted or converted: signers sign the archive as a whole, and the signed result is an ASiC-E container.
- A signable ZIP document requires qualified (QES) signing methods, for example MitID QES, BankID Norway or Belgian eID. Use
"signable": falseto attach a ZIP that is not signed. - The file content must match the extension of
name: a ZIP namedcontract.pdfis rejected. - Office documents (DOCX, XLSX, PPTX, ODT, ...) are not accepted as documents, even renamed to
.zip. Convert them to PDF, or put them inside a ZIP archive. - A ZIP archive must not be empty, must contain at most 100 files, and file names inside it must be at most 255 characters, without absolute paths,
..segments, control characters or duplicates. - Each file can be at most 100 MB, and the whole request must stay under 110 MB.
CC Recipients Array [Optional]
CC Recipients are people who should receive a copy of the case file documents but do not need to sign. They will be notified when the case file is completed.
Required Properties
| Property | Type | Description | Constraints |
|---|---|---|---|
name | string | Name of the recipient. | Min: 1 character |
email | string | Email of the recipient. | Valid email format |
Optional Properties
| Property | Type | Description | Constraints |
|---|---|---|---|
storeAsContact | boolean | Signer will be stored as a contact. Defaults to true. | - |
Minimal Example
Here's a minimal example with only required fields:
{
"caseFile": {
"title": "Simple Agreement",
"signers": [
{
"name": "John Doe",
"email": "[email protected]"
}
],
"documents": [
{
"title": "Agreement Document",
"name": "agreement.pdf"
}
]
}
}Extended Example
Here's a comprehensive example with most fields populated:
Make sure you pass in the files array of the main payload the corresponding files with names matching the
nameproperties in thedocumentsarray. In our example you will have to submit terms.pdf and contract.pdf files.You can also pass a folderId in the caseFile object to place the case file in a specific folder.
Set
activateCaseFiletofalseon thecaseFileobject if you want to create the case file without activating/sending it. In that case the job result still returnscaseFile.id, butsigningLinksis[].
{
"caseFile": {
"title": "Commercial Agreement 2025",
"senderName": "Acme Signing Department",
"language": "en",
"reference": "CONTRACT-2025-001",
"metaData": "Q4 Commercial Contract",
"sensitiveData": false,
"disableNotificationsOwner": false,
"disableEmailAttachments": false,
"signOnMeeting": false,
"sendAt": null,
"expireAt": null,
"visibilityMode": 0,
"signers": [
{
"name": "John Doe",
"email": "[email protected]",
"role": "Client Signer",
"language": "en",
"socialSecurityNumberPlain": null,
"ssnType": "dk:cpr",
"vatin": null,
"signOrder": 0,
"onBehalfOf": "Acme Corporation",
"accessControl": true,
"storeAsContact": true,
"successUrl": "https://example.com/success",
"failUrl": "https://example.com/failure",
"emailFormat": "html",
"emailSubject": "Please sign the commercial agreement",
"emailText": "Dear John, please review and sign the attached commercial agreement.",
"reminderInterval": 3,
"reminderEmailSubject": "Reminder: Please sign the commercial agreement",
"reminderEmailText": "This is a friendly reminder to sign the agreement.",
"completedEmailSubject": "Thank you for signing",
"completedEmailText": "The agreement has been completed successfully.",
"activeAt": null,
"expireAt": null,
"enableInsecureSigning": false,
"insecureSigningMethods": [],
"secureSigningMethods": [
"mitid.dk",
"bankid_se",
"bankid_no",
"itsme.be",
"ftn.fi",
"eid.belgium.be",
"passport-reader.eident.dk",
"ausweisapp.bund.de",
"qes.personal.mitid.dk",
"qes.erhverv.mitid.dk"
]
},
{
"name": "Jane Smith",
"email": "[email protected]",
"role": "Company Signer",
"channel": "eboks",
"cpr": "1234567890",
"language": "en",
"signOrder": 1,
"onBehalfOf": "Company Ltd",
"accessControl": false,
"storeAsContact": true,
"emailFormat": "text",
"reminderInterval": 2
}
],
"ccRecipients": [
{
"name": "John CC",
"email": "[email protected]",
"storeAsContact": false
},
{
"name": "Jane CC",
"email": "[email protected]",
"storeAsContact": true
}
],
"documents": [
{
"title": "Main Contract",
"name": "contract.pdf",
"signable": true,
"roles": [
"Client Signer",
"Company Signer"
],
"documentOrder": 0,
"metaData": "Primary contract document"
},
{
"title": "Terms and Conditions",
"name": "terms.pdf",
"signable": false,
"metaData": "Standard T&C attachment"
}
]
}
}Note:
secureSigningMethodsfield contains all the available secure signing methods for explanatory purposes. Your customer most likely has only some of these methods enabled. In order to have a successful send, use only the methods that are available for your account. If you want to use all available methods, do not pass thesecureSigningMethodsfield at all and we will automatically use all methods enabled for your account. If you want to use only a subset of the available methods, pass thesecureSigningMethodsfield with only the methods you want to use. If you want to use no secure signing methods (SES-only when insecure signing is enabled), pass an empty array in thesecureSigningMethodsfield.
Usage Notes
1. Sending the Data (One Case File)
Each request creates one case file. You can create up to 60 case files per minute per Penneo user; over that limit, the request is rejected with 429 Too Many Requests and a Retry-After header in seconds. Wait Retry-After + 1 seconds before you send again. To create many case files, see Multiple Case Files Creation - One by One.
The data field must be stringified when sending in a multipart/form-data request:
import { readFileSync } from "fs";
// 1. Build CaseFile JSON data
const caseFileData = {caseFile: {
title: "My Contract",
signers: [
{ name: "John Doe", email: "[email protected]" },
{ name: "Jane Doe", email: "[email protected]" }
],
documents: [
{ title: "Contract", name: "contract.pdf" }, //name matches first uploaded doc
{ title: "Appendix", name: "appendix.pdf" } //name matches second uploaded doc
]
}};
// 2. Load files directly into File objects
const contractPdfBytes = readFileSync("./contract.pdf");
const appendixPdfBytes = readFileSync("./appendix.pdf");
const contractFile = new File([contractPdfBytes], "contract.pdf", {
type: "application/pdf"
});
const appendixFile = new File([appendixPdfBytes], "appendix.pdf", {
type: "application/pdf"
});
// 3. FormData
const formData = new FormData();
// JSON data field
formData.append(
"data",
JSON.stringify(caseFileData) // <-- Append the JSON as a simple string
);
// Add PDF files
formData.append("files", contractFile, contractFile.name);
formData.append("files", appendixFile, appendixFile.name);
// 4. POST request
const response = await fetch(
"https://sandbox.penneo.com/send/api/v1/casefiles/20251022/create",
{
method: "POST",
headers: {
"X-Auth-Token": "YOUR_TOKEN_HERE" // Retrieve the JWT Token based on https://developer.penneo.com/docs/using-oauth#api-keys-grant
},
body: formData
}
);
// 5. Handle Penneo response
if (!response.ok) {
console.error("Status code:", await response.status);
console.error("Error:", await response.text());
process.exit(1);
}
console.log("Success:", await response.json());
2. Multiple Case Files Creation - One by One
This flow creates case files one by one, one request per case file.
Both endpoints this flow calls are rate limited:
| Endpoint | Limit | On 429 Too Many Requests |
|---|---|---|
POST /send/api/v1/casefiles/20251022/create | 60 requests per minute per Penneo user | Retry-After header, in seconds |
POST /send/api/v1/queue/public/status/batch | 20 checks per minute per job (uuid + payloadHash); the request itself is also limited per IP address | A job over its limit comes back rate_limited with retryAfter, in seconds; a request over the IP limit gets Retry-After header, in seconds |
The script below sends its create requests a few at a time (CREATE_CONCURRENCY, 4 by default), then checks the status of all their jobs together every 3 seconds with the batch status check (POST /send/api/v1/queue/public/status/batch, see Checking Several Jobs (Batch) in the Status Check documentation), up to 500 jobs per request. The script is done when every case file's job has a final answer, a success (completed) or an error, and none is left pending or processing.
A rate limit is not an error: after a 429, the script waits Retry-After + 1 seconds, so the rate limit window has surely reset, and tries again, up to a maximum number of attempts. A job answered rate_limited is checked again after its retryAfter + 1 seconds, while the other jobs carry on.
import { readFileSync } from "fs";
const BASE_URL = "https://sandbox.penneo.com/send/api/v1";
const TOKEN = "YOUR_TOKEN_HERE"; // Retrieve the JWT Token based on https://developer.penneo.com/docs/using-oauth#api-keys-grant
const CREATE_CONCURRENCY = 4; // create requests in flight at the same time
const MAX_CREATE_ATTEMPTS = 5; // per case file, counting the ones rejected with 429
const POLL_INTERVAL_SECONDS = 3; // stays within 20 status checks per job per minute
const MAX_STATUS_ROUNDS = 200; // about 10 minutes
const STATUS_BATCH_SIZE = 500; // jobs per batch status request
// 1. The case files to create, each with the PDF files its documents name.
const caseFiles = [
{
data: {
caseFile: {
title: "Contract - John Doe",
signers: [{ name: "John Doe", email: "[email protected]" }],
documents: [{ title: "Contract", name: "contract-john.pdf" }]
}
},
files: ["./contract-john.pdf"]
},
{
data: {
caseFile: {
title: "Contract - Jane Doe",
signers: [{ name: "Jane Doe", email: "[email protected]" }],
documents: [{ title: "Contract", name: "contract-jane.pdf" }]
}
},
files: ["./contract-jane.pdf"]
}
];
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// After a 429: Retry-After + 1 seconds, so the next attempt lands in a fresh rate limit window.
const secondsToWait = (response) => (Number(response.headers.get("Retry-After")) || 60) + 1;
function buildFormData(item) {
const formData = new FormData();
formData.append("data", JSON.stringify(item.data));
for (const path of item.files) {
const name = path.split("/").pop();
formData.append("files", new File([readFileSync(path)], name, { type: "application/pdf" }), name);
}
return formData;
}
// 2. Create one case file, waiting out any 429.
async function createCaseFile(item) {
const title = item.data.caseFile.title;
for (let attempt = 1; attempt <= MAX_CREATE_ATTEMPTS; attempt++) {
const response = await fetch(`${BASE_URL}/casefiles/20251022/create`, {
method: "POST",
headers: { "X-Auth-Token": TOKEN },
body: buildFormData(item)
});
if (response.status === 429) {
const wait = secondsToWait(response);
console.warn(`[${title}] create rate limited (attempt ${attempt}/${MAX_CREATE_ATTEMPTS}), retrying in ${wait}s`);
await sleep(wait);
continue;
}
if (!response.ok) {
throw new Error(`[${title}] create failed with ${response.status}: ${await response.text()}`);
}
const { jobs } = await response.json();
// Store uuid and payloadHash now: they are returned only once.
console.log(`[${title}] job created: ${jobs[0].uuid}`);
return { title, uuid: jobs[0].uuid, payloadHash: jobs[0].payloadHash };
}
throw new Error(`[${title}] still rate limited after ${MAX_CREATE_ATTEMPTS} create attempts`);
}
// 3. Check the status of every job until none is pending or processing.
// Returns, per job uuid, { result } on success or { error } on failure.
async function waitForJobs(jobs) {
const outcomes = new Map();
const pending = new Map(jobs.map((job) => [job.uuid, job]));
const notBefore = new Map(); // rate_limited jobs: when they can be checked again
for (let round = 1; round <= MAX_STATUS_ROUNDS && pending.size > 0; round++) {
const due = [...pending.values()].filter((job) => (notBefore.get(job.uuid) ?? 0) <= Date.now());
for (let start = 0; start < due.length; start += STATUS_BATCH_SIZE) {
const chunk = due.slice(start, start + STATUS_BATCH_SIZE);
const response = await fetch(`${BASE_URL}/queue/public/status/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// Send only uuid and payloadHash: the endpoint rejects any other property.
body: JSON.stringify({ jobs: chunk.map(({ uuid, payloadHash }) => ({ uuid, payloadHash })) })
});
if (response.status === 429) {
// Over the per-IP limit: wait, then check the rest in the next round.
await sleep(secondsToWait(response));
break;
}
if (!response.ok) {
throw new Error(`Status check failed with ${response.status}: ${await response.text()}`);
}
const { jobs: entries } = await response.json();
for (const entry of entries) {
if (entry.status === "rate_limited") {
notBefore.set(entry.uuid, Date.now() + (entry.retryAfter + 1) * 1000);
continue;
}
if (entry.status === "not_found") {
outcomes.set(entry.uuid, { error: "job not found" });
} else if (entry.job.jobStatus === "completed") {
outcomes.set(entry.uuid, { result: entry.job.result });
} else if (entry.job.jobStatus === "pending" || entry.job.jobStatus === "processing") {
continue;
} else {
outcomes.set(entry.uuid, { error: `job ${entry.job.jobStatus}: ${entry.job.errorMessage}` });
}
pending.delete(entry.uuid);
}
}
if (pending.size > 0) {
await sleep(POLL_INTERVAL_SECONDS);
}
}
for (const uuid of pending.keys()) {
outcomes.set(uuid, { error: `job not finished after ${MAX_STATUS_ROUNDS} rounds` });
}
return outcomes;
}
// 4. Create the case files, CREATE_CONCURRENCY at a time: when one request finishes, the next one starts.
const created = [];
const queue = [...caseFiles];
async function createNext() {
while (queue.length > 0) {
const item = queue.shift();
try {
created.push(await createCaseFile(item));
} catch (error) {
console.error(error.message);
}
}
}
await Promise.all(Array.from({ length: CREATE_CONCURRENCY }, createNext));
// 5. Wait for all the jobs, then print each result.
const outcomes = await waitForJobs(created);
for (const { title, uuid } of created) {
const { result, error } = outcomes.get(uuid);
if (error) {
console.error(`[${title}] ${error}`);
continue;
}
console.log(`[${title}] case file ${result.data.caseFile.id} created`);
for (const link of result.data.signingLinks) {
console.log(`[${title}] ${link.name} (${link.role}): ${link.signingLink}`);
}
}
Each create request uploads its PDF files, so the script keeps only CREATE_CONCURRENCY of them in flight: sending dozens of uploads at the same time can make connections time out (fetch failed: UND_ERR_CONNECT_TIMEOUT). All create requests count against the one limit of your Penneo user, so up to 60 case files are created per minute. A request over the limit gets 429, waits its Retry-After + 1 seconds and is sent again, while the other requests carry on. Each job has its own status check limit, so checking all of them together in one request is safe.
When both jobs of the example have completed, the last batch status response looks like this:
{
"jobs": [
{
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
"status": "found",
"rateLimit": { "limit": 20, "remaining": 16, "reset": 42 },
"job": {
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "etl-queue",
"payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
"jobStatus": "completed",
"errorMessage": null,
"retries": 0,
"maxRetries": 0,
"createdAt": "2026-10-14T07:07:51.130Z",
"updatedAt": "2026-10-14T07:07:54.873Z",
"result": {
"data": {
"caseFile": { "id": 1234 },
"signingLinks": [
{
"name": "John Doe",
"role": "signer",
"signerId": 5678,
"signOrder": [0],
"signingLink": "https://sandbox.penneo.com/signing/AAAAA-BBBBB-CCCCC-DDDDD-EEEEE-FFFFF"
}
]
},
"errors": null,
"success": true
}
}
},
{
"uuid": "7e47477c-e543-43ad-90a9-50e6abe9abdc",
"payloadHash": "1d3644ac95ec37b073d9d8be2dca5da66f36533397a88c0462dcc219d049db70",
"status": "found",
"rateLimit": { "limit": 20, "remaining": 19, "reset": 60 },
"job": {
"uuid": "7e47477c-e543-43ad-90a9-50e6abe9abdc",
"type": "etl-queue",
"payloadHash": "1d3644ac95ec37b073d9d8be2dca5da66f36533397a88c0462dcc219d049db70",
"jobStatus": "completed",
"errorMessage": null,
"retries": 0,
"maxRetries": 0,
"createdAt": "2026-10-14T07:08:41.343Z",
"updatedAt": "2026-10-14T07:08:45.465Z",
"result": {
"data": {
"caseFile": { "id": 1235 },
"signingLinks": [
{
"name": "Jane Doe",
"role": "signer",
"signerId": 5679,
"signOrder": [0],
"signingLink": "https://sandbox.penneo.com/signing/GGGGG-HHHHH-IIIII-JJJJJ-KKKKK-LLLLL"
}
]
},
"errors": null,
"success": true
}
}
}
]
}3. Role Matching
- If you specify
rolesin documents, only signers with matchingrolevalues can sign those documents - Roles must be at least 3 characters. Any characters are allowed, including letters, numbers, spaces, and punctuation. Roles must not start or end with whitespace
- If no roles are specified on documents, all signers can sign all documents
4. File Matching
- The
nameproperty in each document must match the filename of the uploaded file namemust be a.pdfor.zipfilename matching the file content, and must not contain: " / \ * ? < > | ; = # %or control characters.Price list 2026:3.pdfis rejected;Price list 2026-3.pdfis accepted.- Unsupported or mismatching files are rejected with
400 Bad Requestbefore the case file is created. See PDF and ZIP documents.
5. Communication channel (e-Boks)
- Omit
channel, or set it to'email', to deliver via email (the default) - Set
channelto'eboks'to deliver via e-Boks. Provide exactly one ofcpr(exactly 10 digits) orcvr(8 digits, optional alphanumeric:suffix) - Do not send
cprorcvrunlesschannelis'eboks' - The recipient name is always the signer
name. Language is the signerlanguage, then the case filelanguage, then'da'when neither is set. - Email is not necessary for e-Boks delivery
- The customer must have e-Boks enabled. Any customer admin can request this feature from
Configuration→Company→Communication Channelssection.
6. Timestamps
All timestamp fields (sendAt, expireAt, activeAt) use Unix epoch time (seconds since January 1, 1970 UTC).
// JavaScript example to get Unix timestamp
const timestamp = Math.floor(Date.now() / 1000);Error Handling
If validation fails, you'll receive a 400 Bad Request response with details about which fields failed validation. Common errors:
- Missing required fields: Ensure
title,signers, anddocumentsare provided - Invalid role format: Roles cannot start or end with whitespace, and must be at least 3 characters
- Email format: Must be a valid email address
- String length: Check minimum/maximum length requirements
- Invalid enum values: Language, ssnType, emailFormat, and channel must use exact values listed above
- e-Boks identifier: When
channeliseboks, provide exactly one ofcprorcvr - e-Boks not enabled: The customer must have e-Boks enabled. Failed jobs report:
e-Boks is an add-on service that is currently disabled for this customer. To activate it, the customer needs to sign an e-Boks appendix and accept the extra pricing. Please reach out to the Penneo Sales team to generate the contract and have e-Boks enabled. - File mismatch: Document
namemust match the uploaded PDF or ZIP filename, and the content must match its extension - Unsupported file: Only PDF and ZIP documents are accepted (see PDF and ZIP documents)
- Unsafe filename: Document
namemust be a.pdfor.zipfilename without characters that break media upload (: " / \ * ? < > | ; = # %)
If you send more than 60 create requests per minute for one Penneo user, you'll receive a 429 Too Many Requests response. Wait the number of seconds in its Retry-After header, plus one, before you send again (see Multiple Case Files Creation - One by One).
Credentials & Testing
To interact with the Penneo API, you need to authenticate using JWT tokens.
Follow these steps:
- Make sure you have your OAuth Client ID and Client Secret for the environment you are implementing. If you don't have a set, contact Penneo support to have them issued.
- Generate a JWT token as described in the Authentication Guide using API Keys Grant.
- With the JWT Token, you can make authorized requests to this endpoint for the lifetime of the JWT Token.
- Once you submit a CaseFile, you can check its status using the
Check job status by UUIDendpoint. We support polling and we encourage you to check for a job status no more often than every 3 seconds until the job is complete.
Checking Job Status
When you submit a case file, the job is created asynchronously and the response returns a job UUID and payloadHash. Use these two values with the Check job status by UUID endpoint to poll for completion and obtain the case file ID. To follow several jobs at once, send up to 500 pairs to the batch status check, POST /send/api/v1/queue/public/status/batch, instead (see Multiple Case Files Creation - One by One).
The case file ID is what you use to track the case file afterwards (for example, its signing progress).
Job status records are retained for 90 days after the job is created, after which the status endpoint returns 404 Not Found and the batch status endpoint answers not_found. This retention applies only to the job status record — the case file itself is not deleted and remains available in Penneo.
