> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dnclatam.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Durable Scrub Jobs

> Submit larger arrays without holding an HTTP connection open.

Use the durable API when a batch is larger than the synchronous limit or may
need to wait for a country service. The public API always accepts JSON arrays;
CSV remains a panel-only workflow.

## Choose the API

| API              |                                                         Input limit | Response                                                 | Retention                                                                                                                                                                |
| ---------------- | ------------------------------------------------------------------: | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST /v1/scrub` |                                                       10,000 inputs | `200` with the complete result                           | No input or result persistence; idempotency fingerprint for 7 days                                                                                                       |
| `POST /v2/scrub` | Up to 500,000 inputs and 25 MiB of JSON body, whichever comes first | `202` with a job resource, or `200` on a terminal replay | Input purge due at terminal state, or unknown-upload revalidation due at the 24-hour hard cap; result for 7 days from success; metadata for 30 days from terminalization |

The v1 execution budget is 15 seconds. A v1 request that cannot finish within
that budget fails as a complete unit; it does not become a hidden background
job. Submit the same logical work to v2 when it needs durable execution.

The v2 admission budget is 5 seconds. It covers authentication, validation,
entitlement, Customer Quota reservation, and enqueueing only; it does not
include country execution or internal retries.

## Submit a job

Colombia is enabled for durable jobs. After the initial invoice is paid, the
organization's Colombia Country Purchase becomes active immediately and a
valid request can return `202 Accepted`; no manual activation is required.
Normal entitlement, quota, maintenance, and temporary provider-availability
checks still apply at admission.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.dnclatam.com/v2/scrub \
    -X POST \
    -H "Authorization: Bearer ${DNC_LATAM_API_KEY}" \
    -H "Idempotency-Key: ${DNC_LATAM_IDEMPOTENCY_KEY}" \
    -H "Content-Type: application/json" \
    -d '{
      "country": "co",
      "phones": ["+57 300 555 0101", "+57 601 555 0102"]
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch("https://api.dnclatam.com/v2/scrub", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DNC_LATAM_API_KEY}`,
      "Idempotency-Key": process.env.DNC_LATAM_IDEMPOTENCY_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      country: "co",
      phones: ["+57 300 555 0101", "+57 601 555 0102"],
    }),
  });

  if (response.status !== 200 && response.status !== 202) {
    const error = await response.json();
    throw new Error(`admission failed: ${response.status} ${error.error}`);
  }

  const job = await response.json();
  ```

  ```python Python theme={null}
  import os

  import requests

  response = requests.post(
      "https://api.dnclatam.com/v2/scrub",
      headers={
          "Authorization": f"Bearer {os.environ['DNC_LATAM_API_KEY']}",
          "Idempotency-Key": os.environ["DNC_LATAM_IDEMPOTENCY_KEY"],
      },
      json={
          "country": "co",
          "phones": ["+57 300 555 0101", "+57 601 555 0102"],
      },
      timeout=30,
  )

  if response.status_code not in (200, 202):
      raise RuntimeError(f"admission failed: {response.status_code} {response.json()['error']}")

  job = response.json()
  ```
</CodeGroup>

The API returns `202 Accepted`:

```json theme={null}
{
  "job_id": "7a8f6d2c-4b1e-4f82-9d73-2c5e9b1a6f40",
  "status": "queued",
  "country": "co",
  "submitted_count": 2,
  "created_at": "2026-08-21T15:00:00Z",
  "status_url": "https://api.dnclatam.com/v2/scrub/7a8f6d2c-4b1e-4f82-9d73-2c5e9b1a6f40",
  "result_url": "https://api.dnclatam.com/v2/scrub/7a8f6d2c-4b1e-4f82-9d73-2c5e9b1a6f40/result",
  "cancel_url": "https://api.dnclatam.com/v2/scrub/7a8f6d2c-4b1e-4f82-9d73-2c5e9b1a6f40/cancel",
  "expires_at": "2026-08-22T15:00:00Z"
}
```

The identifier is an opaque lowercase UUID. Do not infer a country,
organization, tenant, provider, or execution state from it. Tenant identity is
internal and is never returned in the public job resource.

An exact retry uses the same idempotency key, country, and array. It returns
the same job identity and does not reserve Customer Quota again. Active jobs
replay as `202`; jobs that have already terminalized replay as `200`. Reusing the
key with a different payload returns `409 idempotency_key_reused`.

## Poll status

```bash theme={null}
curl "https://api.dnclatam.com/v2/scrub/${JOB_ID}" \
  -H "Authorization: Bearer ${DNC_LATAM_API_KEY}"
```

The public state machine is:

```text theme={null}
queued → running → succeeded
                 ↘ failed
                 ↘ cancelled
                 ↘ expired
queued ───────────→ cancelled
```

`queued` and `running` are non-terminal. `succeeded`, `failed`, `cancelled`,
and `expired` are terminal. `expired` means the job did not terminalize before
the 24-hour active-job/input hard cap: Customer Quota is released and input is
due for exact-identity cleanup or already verified absent. `expires_at` is state-dependent: admission plus 24 hours while queued
or running; successful terminalization plus 7 days for a succeeded job; and
terminalization plus 30 days for failed, cancelled, or expired metadata.
A status response never includes provider names,
attempt counts, chunks, costs, balances, credentials, or raw provider errors.

Poll with bounded backoff and honor `Retry-After` when it is present. Do not
create a new job on every poll or retry an admission with a new key unless you
intend to submit a new logical batch.

## Retrieve the result

```bash theme={null}
curl "https://api.dnclatam.com/v2/scrub/${JOB_ID}/result" \
  -H "Authorization: Bearer ${DNC_LATAM_API_KEY}"
```

Only a `succeeded` job has a result. The result uses the same
`prohibited_numbers_to_call`, `safe_numbers_to_call`, `invalid_numbers`, and
`summary` model as v1. A job is always complete: the API never presents an
internal chunk that failed as a partial success.

* `409 job_result_not_ready`: the job is queued, running, failed, or
  cancelled. It may include a `Retry-After` header while queued or running;
  failed and cancelled jobs are terminal and have no complete result to
  retrieve. A `failed` status always includes a `JobError`; a `cancelled`
  status never includes one because cancellation is not an error.
* `410 job_expired`: the job did not terminalize before its 24-hour hard cap;
  its reservation was released and input cleanup is due or already verified.
* `410 job_result_expired`: the successful result exceeded its seven-day
  retention window.
* `404 job_not_found`: the job does not exist or belongs to another
  organization. Both cases intentionally look the same to prevent
  cross-tenant enumeration.

Unlike v1, v2 temporarily stores the input and successful result encrypted so
the job can survive a request ending. An attached input is eligible for purge
when the job terminalizes. If an upload finishes with an unknown outcome, its
exact canonical object becomes due for revalidation at the 24-hour hard cap. A
failed cleanup remains retryable and observable until exact absence is
verified. Results and non-phone metadata are purged according to the TTLs above.
Every v2 response, including errors, exposes
`x-retention: transient-encrypted`; technical v2 responses
also expose the effective per-organization `X-RateLimit-Limit`,
`X-RateLimit-Remaining`, and `X-RateLimit-Reset`, while 429 responses include
`Retry-After`. The safe technical default is 60 requests per 60-second window;
operator overrides are bounded from 1 to 60,000 and are independent of
Customer Quota. The public contract does not claim that a government retains
or deletes data on the same schedule.

## Complete polling example

The API does not deliver webhooks; polling the status resource is the
integration model. The examples below submit a batch, poll until the job is
terminal while honoring `Retry-After` and applying bounded backoff with
jitter, and retrieve the result only after `succeeded`.

<CodeGroup>
  ```javascript Node.js theme={null}
  const BASE_URL = "https://api.dnclatam.com";
  const TERMINAL_STATES = new Set(["succeeded", "failed", "cancelled", "expired"]);
  const RETRYABLE_CONTROL_STATUSES = new Set([429, 500, 503]);

  const headers = {
    Authorization: `Bearer ${process.env.DNC_LATAM_API_KEY}`,
  };

  function deadlineError() {
    return new Error("durable scrub deadline exceeded");
  }

  function remainingMs(deadlineAt) {
    const remaining = deadlineAt - Date.now();
    if (remaining <= 0) throw deadlineError();
    return remaining;
  }

  async function fetchBeforeDeadline(url, options, deadlineAt) {
    const signal = AbortSignal.timeout(remainingMs(deadlineAt));
    try {
      return await fetch(url, { ...options, signal });
    } catch (error) {
      if (Date.now() >= deadlineAt || error?.name === "TimeoutError") {
        throw deadlineError();
      }
      throw error;
    }
  }

  async function sleepBeforeDeadline(delayMs, deadlineAt) {
    if (delayMs >= remainingMs(deadlineAt)) throw deadlineError();
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  function retryDelayMs(response, attempt) {
    const retryAfter = Number(response.headers.get("retry-after"));
    if (Number.isFinite(retryAfter) && retryAfter > 0) return retryAfter * 1_000;

    const backoff = Math.min(2_000 * 2 ** attempt, 60_000);
    return backoff / 2 + Math.random() * (backoff / 2);
  }

  async function apiError(response) {
    try {
      return (await response.json()).error ?? "unknown_error";
    } catch {
      return "invalid_error_response";
    }
  }

  async function scrubDurably(
    country,
    phones,
    { idempotencyKey, deadlineMs = 2 * 60 * 60 * 1000 },
  ) {
    if (!idempotencyKey) throw new Error("a persisted idempotency key is required");
    if (!Number.isFinite(deadlineMs) || deadlineMs <= 0) {
      throw new RangeError("deadlineMs must be positive");
    }
    const deadlineAt = Date.now() + deadlineMs;

    const submit = await fetchBeforeDeadline(`${BASE_URL}/v2/scrub`, {
      method: "POST",
      headers: {
        ...headers,
        "Idempotency-Key": idempotencyKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ country, phones }),
    }, deadlineAt);
    if (submit.status !== 200 && submit.status !== 202) {
      throw new Error(`admission failed: ${submit.status} ${await apiError(submit)}`);
    }
    const job = await submit.json();

    let attempt = 0;
    let status = job;

    while (!TERMINAL_STATES.has(status.status)) {
      const poll = await fetchBeforeDeadline(job.status_url, { headers }, deadlineAt);
      if (RETRYABLE_CONTROL_STATUSES.has(poll.status)) {
        const delay = retryDelayMs(poll, attempt);
        attempt += 1;
        await poll.body?.cancel();
        await sleepBeforeDeadline(delay, deadlineAt);
        continue;
      }
      if (!poll.ok) {
        throw new Error(`status failed: ${poll.status} ${await apiError(poll)}`);
      }
      status = await poll.json();
      if (TERMINAL_STATES.has(status.status)) break;

      const delay = retryDelayMs(poll, attempt);
      attempt += 1;
      await sleepBeforeDeadline(delay, deadlineAt);
    }

    if (status.status !== "succeeded") {
      // failed and expired carry a JobError object; cancelled does not.
      throw new Error(`job ${job.job_id} ended ${status.status}: ${status.error?.error ?? "no error"}`);
    }

    while (true) {
      const result = await fetchBeforeDeadline(job.result_url, { headers }, deadlineAt);
      if (RETRYABLE_CONTROL_STATUSES.has(result.status)) {
        const delay = retryDelayMs(result, attempt);
        attempt += 1;
        await result.body?.cancel();
        await sleepBeforeDeadline(delay, deadlineAt);
        continue;
      }
      if (!result.ok) {
        throw new Error(`result failed: ${result.status} ${await apiError(result)}`);
      }
      return result.json();
    }
  }

  const idempotencyKey = process.env.DNC_LATAM_IDEMPOTENCY_KEY;
  const result = await scrubDurably(
    "co",
    ["+57 300 555 0101", "+57 601 555 0102"],
    { idempotencyKey },
  );
  console.log(result.summary);
  ```

  ```python Python theme={null}
  import os
  import random
  import time

  import requests

  BASE_URL = "https://api.dnclatam.com"
  TERMINAL_STATES = {"succeeded", "failed", "cancelled", "expired"}
  RETRYABLE_CONTROL_STATUSES = {429, 500, 503}

  HEADERS = {"Authorization": f"Bearer {os.environ['DNC_LATAM_API_KEY']}"}


  def remaining_seconds(deadline):
      remaining = deadline - time.monotonic()
      if remaining <= 0:
          raise TimeoutError("durable scrub deadline exceeded")
      return remaining


  def request_timeout(deadline):
      return min(30, remaining_seconds(deadline))


  def sleep_before_deadline(delay, deadline):
      if delay >= remaining_seconds(deadline):
          raise TimeoutError("durable scrub deadline exceeded")
      time.sleep(delay)


  def retry_delay(response, attempt):
      retry_after = response.headers.get("Retry-After")
      if retry_after:
          try:
              if int(retry_after) > 0:
                  return int(retry_after)
          except ValueError:
              pass

      backoff = min(2 * 2**attempt, 60)
      return backoff / 2 + random.uniform(0, backoff / 2)


  def api_error(response):
      try:
          return response.json().get("error", "unknown_error")
      except requests.exceptions.JSONDecodeError:
          return "invalid_error_response"


  def scrub_durably(country, phones, idempotency_key, deadline_seconds=2 * 60 * 60):
      if not idempotency_key:
          raise ValueError("a persisted idempotency key is required")
      if deadline_seconds <= 0:
          raise ValueError("deadline_seconds must be positive")
      deadline = time.monotonic() + deadline_seconds

      submit = requests.post(
          f"{BASE_URL}/v2/scrub",
          headers={**HEADERS, "Idempotency-Key": idempotency_key},
          json={"country": country, "phones": phones},
          timeout=request_timeout(deadline),
      )
      if submit.status_code not in (200, 202):
          raise RuntimeError(f"admission failed: {submit.status_code} {api_error(submit)}")
      job = submit.json()

      attempt = 0
      status = job

      while status["status"] not in TERMINAL_STATES:
          poll = requests.get(
              job["status_url"],
              headers=HEADERS,
              timeout=request_timeout(deadline),
          )
          if poll.status_code in RETRYABLE_CONTROL_STATUSES:
              delay = retry_delay(poll, attempt)
              attempt += 1
              poll.close()
              sleep_before_deadline(delay, deadline)
              continue
          if not poll.ok:
              raise RuntimeError(f"status failed: {poll.status_code} {api_error(poll)}")
          status = poll.json()
          if status["status"] in TERMINAL_STATES:
              break

          delay = retry_delay(poll, attempt)
          attempt += 1
          sleep_before_deadline(delay, deadline)

      if status["status"] != "succeeded":
          # failed and expired carry a JobError object; cancelled does not.
          raise RuntimeError(
              f"job {job['job_id']} ended {status['status']}: {(status.get('error') or {}).get('error')}"
          )

      while True:
          result = requests.get(
              job["result_url"],
              headers=HEADERS,
              timeout=request_timeout(deadline),
          )
          if result.status_code in RETRYABLE_CONTROL_STATUSES:
              delay = retry_delay(result, attempt)
              attempt += 1
              result.close()
              sleep_before_deadline(delay, deadline)
              continue
          if not result.ok:
              raise RuntimeError(f"result failed: {result.status_code} {api_error(result)}")
          return result.json()


  result = scrub_durably(
      "co",
      ["+57 300 555 0101", "+57 601 555 0102"],
      os.environ["DNC_LATAM_IDEMPOTENCY_KEY"],
  )
  print(result["summary"])
  ```
</CodeGroup>

Generate one idempotency key per logical batch and persist it with your own
job record before the first admission attempt. The examples require that
persisted value through `DNC_LATAM_IDEMPOTENCY_KEY`; reuse it only to retry the
exact same country and phone array.

## Cancel a job

```bash theme={null}
curl "https://api.dnclatam.com/v2/scrub/${JOB_ID}/cancel" \
  -X POST \
  -H "Authorization: Bearer ${DNC_LATAM_API_KEY}"
```

Cancellation is an explicit, idempotent action. A queued job can be cancelled
immediately. If a government call is already in progress, cancellation is
accepted but the job remains `running` until its external effect is
reconciled. Once reconciliation establishes the provider call's outcome, the
job becomes `cancelled`, its complete result is discarded, and Customer Quota
is released. A job that was already terminal keeps its terminal state. The API
never claims that an uncertain call was cancelled, never returns a partial
result, and never charges Customer Quota for an incomplete batch.

## Country failures and quota

If a country is in a known maintenance window, the job fails with
`country_service_maintenance`. Other temporary country failures use
`country_service_temporarily_unavailable`. Both errors include the country,
may include `Retry-After`, return no partial result, and consume no Customer
Quota. A paid country that is still `pending_activation` is deliberately
reported as `country_service_temporarily_unavailable`; the API never exposes
that state, Registry Capacity, provider balance, or prepaid credits.

Internal durable classifications map to a closed public error vocabulary:

| Internal outcome class                      | Public `JobError`                         |
| ------------------------------------------- | ----------------------------------------- |
| Known country maintenance                   | `country_service_maintenance`             |
| Temporary or inconclusive country execution | `country_service_temporarily_unavailable` |
| DNC LATAM worker, storage, or lease failure | `platform_failure`                        |
| Active-job hard cap                         | `job_expired`                             |
| Cancellation                                | No `JobError`; status is `cancelled`      |

Admission and provider-capacity failures map to the same generic public
country/platform errors above. The public contract never exposes provider
names, balances, costs, credentials, capacity causes, or raw internal error
codes.
