Retrieves the current status of an asynchronous job by providing the job UUID and payload hash. This endpoint is used to poll for job completion status after submitting a case file creation request. The endpoint is rate-limited to 20 requests per minute per submission; responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and a throttled request gets 429 with Retry-After in seconds. No authentication is required as the UUID and payload hash provide the necessary security.
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Open API 3.0 Specs
The Open API 3.0 Specs are available on the following urls:
Table of Contents
- Overview
- Authentication
- Data Retention
- Request Structure
- Response Structure
- Job Status Values
- Examples
- Checking Several Jobs (Batch)
- Error Handling
- Usage Guide
Overview
When you create a case file using the simple case file creation API, the operation is processed asynchronously. The API immediately returns
a job UUID and payload hash. You must use these two values to poll this status endpoint and, once the job
has completed, retrieve the case file ID. The case file ID is what you use to track the
case file afterwards (for example, its signing progress), so store the case file ID as soon as the job completes.
Key Features:
- No authentication required (UUID + payload hash provide security)
- Rate limited to 20 requests per minute per submission, with a
Retry-Afterheader on429responses - Real-time job status tracking
- Detailed result information, including the case file ID, when completed
- Job status records are retained for 90 days after creation
Authentication
No authentication required for this endpoint. Security is provided through:
- The unique job UUID
- The payload hash
Both values are returned in the response from the simple case file creation endpoint. These two values together ensure only authorized users can check job status.
Data Retention
Job status records — the uuid, payloadHash, payload, and result (which contains the case file ID) — are retained for 90 days after the job is created. After 90 days the record is permanently deleted and the status endpoint returns 404 Not Found.
This retention applies only to the job status record, not to the case file itself. The case file continues to exist in Penneo after the job record expires; you simply track it from then on using its case file ID. So make sure you retrieve and store the case file ID within the 90-day window.
Request Structure
Request Body
The request body must be a JSON object with two required fields.
Required Properties
| Property | Type | Format | Description | Example |
|---|---|---|---|---|
uuid | string | UUID v4 | The unique identifier of the job to check | "123e4567-e89b-12d3-a456-426614174000" |
payloadHash | string | SHA-256 hash | The hash of the original payload used for verification | "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f" |
Request Example
{
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f"
}Getting UUID and PayloadHash
When you create a case file, with the simple case file creation API, the values are returned in the response:
{
"message": "Job created",
"status": "handled",
"jobs": [
{
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
...
}
]
}💡 Tip: Store both the uuid and payloadHash immediately after creating a job so you can check its status later.
Response Structure
Success Response (200 OK)
When the job is found, you receive a complete job object with all details.
Response Properties
| Property | Type | Format | Required | Description |
|---|---|---|---|---|
uuid | string | UUID | Yes | Unique identifier for the job |
jobStatus | string | enum | Yes | Current status of the job. See Job Status Values |
payload | object | JSON | Yes | The original payload submitted with the job |
payloadHash | string | SHA-256 | Yes | SHA-256 hash of the payload |
result | object | null | JSON | Yes | The result of job execution (populated when jobStatus is "completed") |
createdAt | string | ISO 8601 | Yes | Date and time when the job was created |
updatedAt | string | ISO 8601 | Yes | Date and time when the job was last updated |
errorMessage | string | null | - | Yes | Error message if job failed (only present when jobStatus is "failed") |
When the create request sets
caseFile.activateCaseFiletofalse,result.datastill includes thecaseFile.id, butsigningLinksis an empty array ([]) because the case file was not activated/sent.
If a signer uses
channel: "eboks"and e-Boks is not enabled for the customer, the job fails before the case file is created.errorMessageis: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.
Automatic Retry of Failed Jobs
In our response, we return the
retriesandmaxRetriesproperties, which are defaulted to 0. Currently, automatic retry of failed jobs is not enabled as it is part of our experimental features. We will update this information once the mechanism is available and the documentation is complete.
Success Response Example
{
"uuid": "7e47477c-e543-43ad-90a9-50e6abe9abdc",
"type": "etl-queue",
"payloadHash": "1d3644ac95ec37b073d9d8be2dca5da66f36533397a88c0462dcc219d049db70",
"jobStatus": "completed",
"errorMessage": null,
"retries": 0,
"maxRetries": 0,
"createdAt": "2025-11-11T10:34:46.140Z",
"updatedAt": "2025-11-11T10:34:51.574Z",
"result": {
"data": {
"caseFile": {
"id": 1234
},
"signingLinks": [
{
"name": "John Doe",
"role": "signer",
"signerId": 12345,
"signOrder": [
0
],
"signingLink": "https://app.penneo.com/signing/12345-ABCDE-56789-FGHIJ-KLMNO-PQRS"
}
]
},
"errors": null,
"success": true
}
}Error Response (404 Not Found)
When the job is not found or the UUID and payloadHash doesn't match.
Error Properties
| Property | Type | Description | Example |
|---|---|---|---|
statusCode | number | HTTP status code | 404 |
message | string | Human-readable error message | "Job not found" |
Error Response Example
{
"statusCode": 404,
"message": "Job not found"
}Common reasons for 404:
- Job UUID doesn't exist
- PayloadHash doesn't match the job
- Job has expired (see Data Retention)
- Job has been deleted
Job Status Values
The jobStatus field indicates the current state of the job.
| Status | Description | Next Action |
|---|---|---|
pending | Job is waiting to be processed | Continue polling |
processing | Job is currently being executed | Continue polling |
completed | Job finished successfully | Check result field for outcome |
failed | Job execution failed | Check errorMessage field for details |
Status Workflow
┌─────────┐
│ pending │ → Job created, waiting in queue
└────┬────┘
↓
┌────────────┐
│ processing │ → Job is being executed
└─────┬──────┘
↓
┌──┴──────────────────────────┐
│ │
↓ ↓
┌──────────┐ ┌────────┐
│completed │ → get CF defails │ failed │ → Check errorMessage
└──────────┘ └────────┘
Examples
Checking Job Status (Completed)
Request:
const url = 'https://sandbox.penneo.com/send/api/v1/queue/public/status';
const data = {
uuid: '123e4567-e89b-12d3-a456-426614174000',
payloadHash: '3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f'
};
// 120 polls, 5 seconds apart, is about 10 minutes and stays under 20 polls per minute.
const MAX_ATTEMPTS = 120;
const POLL_INTERVAL_SECONDS = 5;
// Used only if a 429 response has no readable Retry-After header: the rate limit window is 60 seconds.
const DEFAULT_RETRY_AFTER_SECONDS = 60;
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
async function checkQueueStatus() {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.status === 429) {
// Wait Retry-After + 1 seconds, so the next attempt lands in a fresh window.
const retryAfter = Number.parseInt(response.headers.get('Retry-After') ?? '', 10);
const wait = (retryAfter > 0 ? retryAfter : DEFAULT_RETRY_AFTER_SECONDS) + 1;
console.warn(`Rate limited (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${wait}s`);
await sleep(wait);
continue;
}
const responseBody = await response.text();
if (!response.ok) {
console.error('Error status:', response.status);
console.error('Error body:', responseBody);
return;
}
const result = JSON.parse(responseBody);
if (result.jobStatus !== 'pending' && result.jobStatus !== 'processing') {
console.log('Queue Status:', result);
return result;
}
await sleep(POLL_INTERVAL_SECONDS);
}
console.error(`Job not finished after ${MAX_ATTEMPTS} status checks`);
}
checkQueueStatus();
To check the status of many jobs in one request, for example after creating several case files one by one, see Checking Several Jobs (Batch).
Response:
{
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "etl-queue",
"payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
"jobStatus": "completed",
"errorMessage": null,
"retries": 0,
"maxRetries": 5,
"createdAt": "2025-11-14T12:12:38.413Z",
"updatedAt": "2025-11-14T12:12:41.957Z",
"result": {
"data": {
"caseFile": {
"id": 1234
},
"signingLinks": [
{
"name": "Test User",
"role": "Signer",
"signerId": 2044563,
"signOrder": [
0
],
"signingLink": "https://sandbox.penneo.com/signing/ABCDE-12345-FGHIJ-KLMNO-56789-PQRST"
}
]
},
"errors": null,
"success": true
}
}Checking Several Jobs (Batch)
POST /send/api/v1/queue/public/status/batch returns the status of up to 500 jobs in one request. Like the single status check, it needs no authentication.
Batch Request
The body holds a jobs array of 1 to 500 distinct uuid + payloadHash pairs, as returned when each case file was created:
{
"jobs": [
{ "uuid": "123e4567-e89b-12d3-a456-426614174000", "payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f" },
{ "uuid": "7e47477c-e543-43ad-90a9-50e6abe9abdc", "payloadHash": "1d3644ac95ec37b073d9d8be2dca5da66f36533397a88c0462dcc219d049db70" }
]
}The request is rejected with 400 Bad Request if jobs is empty, holds more than 500 pairs or repeats a pair, or if a uuid is not a UUID or a payloadHash is not a SHA-256 hash (64 hex characters).
Batch Response (200 OK)
The response has one entry per requested pair, in request order. Each entry repeats the uuid and payloadHash and has a status:
status | Meaning | Other properties |
|---|---|---|
found | The job exists | job: the same job object as the single status check |
not_found | No job has this uuid and payloadHash: a wrong pair, or a job past Data Retention | - |
rate_limited | This job was checked more than 20 times in the last minute | retryAfter: seconds to wait before checking it again |
A missing job does not fail the request: its entry is not_found and the other jobs are still returned. Each entry also has rateLimit (limit, remaining, reset in seconds) for that job, unless the limit could not be read.
{
"jobs": [
{
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
"status": "found",
"rateLimit": { "limit": 20, "remaining": 19, "reset": 60 },
"job": {
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "etl-queue",
"payloadHash": "3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f",
"jobStatus": "completed",
"errorMessage": null,
"retries": 0,
"maxRetries": 0,
"createdAt": "2025-11-14T12:12:38.413Z",
"updatedAt": "2025-11-14T12:12:41.957Z",
"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": "not_found",
"rateLimit": { "limit": 20, "remaining": 19, "reset": 60 }
}
]
}Batch Rate Limits
- Each pair counts against its own limit of 20 checks per minute, the same limit as the single status check: checking a job through either endpoint uses it up. A pair over its limit gets
rate_limitedin its entry; it does not block the other pairs. - The batch request itself is also limited per IP address. Over that limit, the whole request gets
429 Too Many Requests; waitRetry-After+ 1 seconds before sending it again.
When All Jobs Are Finished
The jobs are finished when every job has a final answer and none is left pending or processing:
- Success:
foundwithjobStatuscompleted. The case file ID and signing links are injob.result.data. - Error:
foundwith any other finaljobStatus(for examplefailed, with the reason inerrorMessage), ornot_found.
Keep checking a job while it is found with jobStatus pending or processing, or rate_limited.
Batch Example
The script checks a set of jobs until every job is finished, up to 500 jobs per request. Each round sends only the jobs still waiting for a final answer; a rate_limited job is sent again once its retryAfter + 1 seconds have passed. To create the case files first, see the Multiple Case Files Creation - One by One script in the Case File Data Structure documentation.
const url = 'https://sandbox.penneo.com/send/api/v1/queue/public/status/batch';
// The jobs to follow, as returned when each case file was created.
const jobs = [
{ uuid: '123e4567-e89b-12d3-a456-426614174000', payloadHash: '3a7bd3e2360a3d485f2a5f4d8e6f1e8b9c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f' },
{ uuid: '7e47477c-e543-43ad-90a9-50e6abe9abdc', payloadHash: '1d3644ac95ec37b073d9d8be2dca5da66f36533397a88c0462dcc219d049db70' }
];
// 200 rounds, 3 seconds apart, is about 10 minutes and stays within 20 checks per job per minute.
const MAX_ROUNDS = 200;
const POLL_INTERVAL_SECONDS = 3;
// Jobs per request.
const MAX_BATCH_SIZE = 500;
// Used only if a 429 response has no readable Retry-After header: the rate limit window is 60 seconds.
const DEFAULT_RETRY_AFTER_SECONDS = 60;
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// Per job uuid: { result } on success or { error } on failure.
const outcomes = new Map();
const pending = new Map(jobs.map((job) => [job.uuid, job]));
// Jobs answered rate_limited, with the time they can be checked again.
const notBefore = new Map();
for (let round = 1; round <= MAX_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 += MAX_BATCH_SIZE) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobs: due.slice(start, start + MAX_BATCH_SIZE) })
});
if (response.status === 429) {
// The request is over the per-IP limit: wait Retry-After + 1 seconds, then check the rest in the next round.
const retryAfter = Number.parseInt(response.headers.get('Retry-After') ?? '', 10);
const wait = (retryAfter > 0 ? retryAfter : DEFAULT_RETRY_AFTER_SECONDS) + 1;
console.warn(`Rate limited (round ${round}/${MAX_ROUNDS}), retrying in ${wait}s`);
await sleep(wait);
break;
}
if (!response.ok) {
throw new Error(`Batch 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_ROUNDS} rounds` });
}
// Every job is finished: each one has a success or an error.
for (const { uuid } of jobs) {
const { result, error } = outcomes.get(uuid);
if (error) {
console.error(`[${uuid}] ${error}`);
continue;
}
console.log(`[${uuid}] case file ${result.data.caseFile.id} created`);
for (const link of result.data.signingLinks) {
console.log(`[${uuid}] ${link.name} (${link.role}): ${link.signingLink}`);
}
}
Error Handling
HTTP Status Codes
| Status Code | Description | Action |
|---|---|---|
| 200 OK | Job found and status returned | Process the job status |
| 404 Not Found | Job not found or hash mismatch | Verify UUID and payload hash |
| 429 Too Many Requests | Rate limit exceeded (>20 req/min) | Wait Retry-After + 1 seconds, then retry |
| 500 Internal Server Error | Server error | Retry after a delay |
Rate Limit Headers
Every status check response includes these headers, so you can see how many checks you have left:
| Header | Description |
|---|---|
X-RateLimit-Limit | Checks allowed per minute for this job (20) |
X-RateLimit-Remaining | Checks left in the current minute |
X-RateLimit-Reset | Seconds until the current minute ends and the count resets |
Retry-After | Only on 429: seconds to wait before checking this job again |
Once you exceed the limit, the job is blocked for 60 seconds. Wait the Retry-After seconds plus one before you check again; checks sent earlier are rejected with 429 too.
Usage Guide
Polling Strategy
Implement a polling strategy to check job status without overwhelming the API.
Recommended Polling Intervals
| Job Status | Polling Interval | Max Duration |
|---|---|---|
pending | 5 seconds | 10 minutes, but we expect much faster |
processing | 3-5 seconds | 5 minutes, but we expect much faster |
completed | Stop polling | - |
failed | Stop polling | - |
Best Practices
1. Store Job Details Immediately
After creating a case file, immediately store the uuid and payloadHash — they are returned only once and cannot be
retrieved later. Use them to poll for status and to obtain the case file ID once the job completes; the case file ID
is what you use to track the case file afterwards. Job status records are retained for 90 days after creation, so
retrieve and persist the case file ID within that window.
2. Set Reasonable Timeouts and respect the Rate Limits
Don't poll indefinitely. Set a reasonable timeout:
- Recommended timeout: at least 10 minutes until you see a status change but usually you will have the CaseFile
created or failed in a few seconds depending on the number of signers and documents. - Maximum attempts: 20 polls / minute / CaseFile. After exhausting the limit, 429 HTTP responses will be returned with a
Retry-Afterheader: wait that many seconds plus one before polling again. Also cap the total number of polls (for example 120 polls, 5 seconds apart) so a job that never finishes does not keep you polling. - Polling interval: recommend minimum 3 seconds to fit the maximum attempts but we count the number of attempts per minute, so it is at your discretion if you want to exponentially increased the interval to fit the 20 requests / minute.
Security Considerations
Protect Credentials
The uuid and payloadHash together provide access to job status:
- Store them securely
- Don't share payload hashes/uuid publicly
Integration Workflow
Complete Case File Creation + Status Check Flow
1. Create Case File
↓
2. Receive Job UUID + Payload Hash
↓
3. Store UUID + Hash in Database/Your own storage
↓
4. Start Polling (every 3-5 seconds)
↓
5. Check Job Status
↓
├─→ pending/processing → Continue Polling
├─→ completed → Process Result
└─→ failed → Handle Error
flowchart TD
A[Start: createAndMonitorCaseFile] --> B[Step 1: Create Case File]
B --> C{Is createResponse.status 'handled'?}
C -- No --> D[Throw Error: Failed to create case file]
C -- Yes --> E[Step 2: Store job details in database]
E --> F[Step 3 Poll job status call public/status every 3s]
%% Polling loop
F --> G{completedJob.jobStatus}
G -- pending/processing --> H[Wait 3 seconds then poll again]
H --> F
G -- completed --> I[Update job as 'completed' in database]
I --> J[Return completedJob.result]
G -- failed --> K[Update job as 'failed' in database]
K --> L[Return failedJob.result]