Check job status by UUID

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.

Recent Requests
Log in to see full request history
TimeStatusUser Agent
Retrieving recent requests…
LoadingLoading…

Open API 3.0 Specs

The Open API 3.0 Specs are available on the following urls:

Table of Contents


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-After header on 429 responses
  • 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

PropertyTypeFormatDescriptionExample
uuidstringUUID v4The unique identifier of the job to check"123e4567-e89b-12d3-a456-426614174000"
payloadHashstringSHA-256 hashThe 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

PropertyTypeFormatRequiredDescription
uuidstringUUIDYesUnique identifier for the job
jobStatusstringenumYesCurrent status of the job. See Job Status Values
payloadobjectJSONYesThe original payload submitted with the job
payloadHashstringSHA-256YesSHA-256 hash of the payload
resultobject | nullJSONYesThe result of job execution (populated when jobStatus is "completed")
createdAtstringISO 8601YesDate and time when the job was created
updatedAtstringISO 8601YesDate and time when the job was last updated
errorMessagestring | null-YesError message if job failed (only present when jobStatus is "failed")
📘

When the create request sets caseFile.activateCaseFile to false, result.data still includes the caseFile.id, but signingLinks is 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. errorMessage is: 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 retries and maxRetries properties, 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

PropertyTypeDescriptionExample
statusCodenumberHTTP status code404
messagestringHuman-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.

StatusDescriptionNext Action
pendingJob is waiting to be processedContinue polling
processingJob is currently being executedContinue polling
completedJob finished successfullyCheck result field for outcome
failedJob execution failedCheck 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:

statusMeaningOther properties
foundThe job existsjob: the same job object as the single status check
not_foundNo job has this uuid and payloadHash: a wrong pair, or a job past Data Retention-
rate_limitedThis job was checked more than 20 times in the last minuteretryAfter: 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_limited in 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; wait Retry-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: found with jobStatus completed. The case file ID and signing links are in job.result.data.
  • Error: found with any other final jobStatus (for example failed, with the reason in errorMessage), or not_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 CodeDescriptionAction
200 OKJob found and status returnedProcess the job status
404 Not FoundJob not found or hash mismatchVerify UUID and payload hash
429 Too Many RequestsRate limit exceeded (>20 req/min)Wait Retry-After + 1 seconds, then retry
500 Internal Server ErrorServer errorRetry after a delay

Rate Limit Headers

Every status check response includes these headers, so you can see how many checks you have left:

HeaderDescription
X-RateLimit-LimitChecks allowed per minute for this job (20)
X-RateLimit-RemainingChecks left in the current minute
X-RateLimit-ResetSeconds until the current minute ends and the count resets
Retry-AfterOnly 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 StatusPolling IntervalMax Duration
pending5 seconds10 minutes, but we expect much faster
processing3-5 seconds5 minutes, but we expect much faster
completedStop polling-
failedStop 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-After header: 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]
Body Params

Job identification information including UUID and payload hash for verification.

Request body for checking job status. Both UUID and payloadHash are required for security verification.

uuid
required

The UUID of the job to check. This is returned when the job is created.

string
required

The SHA-256 hash of the payload. This is returned when the job is created and is used to verify the request authenticity.

Responses

Language
URL
LoadingLoading…
Response
Click Try It! to start a request and see the response here! Or choose an example:
application/json