API reference

Authenticate with a Bearer API key. Five endpoints today: single lookup, bulk lookup (up to 100 numbers per call), account snapshot, batch list, and batch download URLs.

On this page

Authentication

Create an API key on the API keys page. Keys look like plc_abcdef... and are shown once at creation time, we store a one-way hash only. Pass the key as a Bearer token:

Authorization: Bearer plc_<your-key>

Suspended accounts and revoked keys are rejected with 401 missing_or_invalid_api_key. Expired keys return 401 expired_api_key.

Scopes

Every key carries one or more scopes. A request that needs a scope the key doesn't have returns 403 missing_scope with the required scope name in detail.

ScopeEndpoints
lookupPOST /v1/lookup
bulkPOST /v1/lookup/bulk

Issue least-privileged keys: a CI-only integration that just calls /v1/lookup only needs the lookup scope.

Expiration

Keys can be set to expire after 30 days, 90 days, 1 year, or never. Expired keys aren't auto-revoked, they just stop authenticating. Rotate before expiry to avoid downtime.

POST /v1/lookup

Look up a single phone number.

Request

POST /v1/lookup
Content-Type: application/json
Authorization: Bearer plc_<your-key>

{
  "phone": "+447400123456",
  "defaultCountry": "GB"   // optional; 2-letter ISO code
}

Example: curl

curl -X POST https://your-app.example.com/v1/lookup \
  -H "Authorization: Bearer plc_abcdefghij1234567890klmnop1234ab" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+447400123456","defaultCountry":"GB"}'

Example: JavaScript (fetch)

const resp = await fetch("https://your-app.example.com/v1/lookup", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.PLC_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ phone: "+447400123456", defaultCountry: "GB" }),
});
const result = await resp.json();

Response

200 OK
Content-Type: application/json

{
  "input_phone": "+447400123456",
  "normalized_e164": "+447400123456",
  "valid": true,
  "country": "GB",
  "country_name": "United Kingdom",
  "region": "Europe",
  "line_type": "mobile",
  "carrier": "EE",
  "sms_ready": true,
  "call_ready": true,
  "reason": "valid_mobile",
  "cached": false,
  "checked_at": "2026-06-13T14:22:01.123Z",
  "error_code": null,
  "credits_debited": 1
}

Errors

StatuserrorMeaning
401missing_or_invalid_api_keyNo header, malformed token, revoked key, or suspended account.
402insufficient_creditsYour balance is 0. Buy more credits before retrying.
400phone_required / defaultCountry_must_be_iso2Request validation failure.

Soft failures (still 200)

Some conditions return 200 with an explanatory reason:

  • malformed_input, empty / non-digit / too short. credits_debited = 0.
  • provider_unavailable, provider hard-failed or daily spend cap was hit. Any debit is refunded. credits_debited = 0.

POST /v1/lookup/bulk

Look up up to 100 phone numbers in one request. Cheaper than 100 round-trips and friendlier on your rate limits.

Request

POST /v1/lookup/bulk
Content-Type: application/json
Authorization: Bearer plc_<your-key>

{
  "phones": [
    "+447400123456",
    "+14155552671",
    "+18005551212"
  ],
  "defaultCountry": "US"   // optional
}

Response

Returns an array of result rows in input order. The array length always matches the request length. If credits run out mid-batch, the tail rows come back with error.error = "insufficient_credits" and creditsExhausted = true.

200 OK

{
  "count": 3,
  "creditsExhausted": false,
  "results": [
    { "index": 0, "result": { /* SingleLookupResult */ } },
    { "index": 1, "result": { /* SingleLookupResult */ } },
    { "index": 2, "result": { /* SingleLookupResult */ } }
  ]
}

Errors

StatuserrorMeaning
401missing_or_invalid_api_keySame as single lookup.
400phones_must_be_array_1_to_100Empty array or more than 100 items.
400all_phones_must_be_stringsOne of the elements wasn't a non-empty string.

GET /v1/account

Read-only account snapshot, balance, key metadata, and current rate-limit budget. Useful for CLI tools and SDKs that want to check balance before kicking off a large bulk call.

Request

GET /v1/account
Authorization: Bearer plc_<your-key>

Response

200 OK

{
  "user_id":                  "8f1e…",
  "balance_credits":          2480,
  "today_provider_spend_usd": 0.0125,
  "today_provider_calls":     14,
  "rate_limit": {
    "minute_remaining": 59,
    "day_remaining":    986
  },
  "key": {
    "id":           "abc…",
    "label":        "ci-server",
    "prefix":       "plc_a1b2c3d4",
    "scopes":       ["lookup", "bulk"],
    "expires_at":   "2026-09-13T00:00:00.000Z",
    "created_at":   "2026-06-13T14:22:01.123Z",
    "last_used_at": "2026-06-13T14:22:55.001Z"
  }
}

GET /v1/batches

List your 25 most recent batches (newest first). Requires the bulk scope on the calling key, batches are a bulk-class resource.

GET /v1/batches
Authorization: Bearer plc_<your-key>

200 OK
{
  "batches": [
    {
      "id":             "<uuid>",
      "status":         "completed",
      "package":        "clean",
      "phone_column":   "phone",
      "default_country":"US",
      "counters": {
        "total_rows":     10000,
        "unique_numbers": 8742,
        "duplicate_rows": 1208,
        "processed_rows": 10000,
        "successful_rows":8702,
        "failed_rows":    40,
        "credits_used":   8742
      },
      "error_code": null,
      "timestamps": {
        "created_at":  "...",
        "started_at":  "...",
        "completed_at":"...",
        "failed_at":   null
      }
    }
  ]
}

GET /v1/batches/:id

Single batch's full summary. Returns the same shape as one entry of GET /v1/batches. 404 if the batch doesn't exist or belongs to another caller.

GET /v1/batches/:id/results

Returns short-lived signed URLs to download both result CSVs (full + SMS-ready). URLs expire ~10 minutes after issue.

200 OK
{
  "batch_id": "<uuid>",
  "urls": {
    "full":      "https://...signed...",
    "sms_ready": "https://...signed..."
  },
  "expires_in_seconds": 600
}

Returns 409 batch_not_completed when the batch is still queued, processing, or has failed.

Idempotency

Add an Idempotency-Key header (any 1-255 character string you pick) to safely retry a request without double-charging. We cache the response by (api_key, idempotency_key) for 24 hours; replays return the original status + body byte-for-byte, with Idempotent-Replayed: true set.

  • Same key, same body → replay (no extra debit, no extra lookup).
  • Same key, different body409 idempotency_key_collision. We don't replay because something inconsistent is going on; you'll want to see it.
  • New key → process normally, cache the response.

Pick a key that uniquely identifies the logical operation, a UUID per attempt at the same lookup is the easy choice. The cache is scoped per API key, so two of your keys can use the same value for unrelated work.

curl -X POST https://your-app.example.com/v1/lookup \
  -H "Authorization: Bearer plc_..." \
  -H "Idempotency-Key: 0c0fa2c3-7c19-4d92-9e5d-1f5b3c5a2f9d" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+447400123456","defaultCountry":"GB"}'

Rate limits

Per-API-key sliding window: 6,000 requests/minute and 1,000,000/day. Same limit on every key. The bulk endpoint counts as one slot regardless of how many phones you pass. That’s 600,000 phones/minute via POST /v1/lookup/bulk with 100 phones per call, six times the largest CSV we accept. You will not feel this in normal usage.

Every response carries four headers so you can back off proactively:

HeaderMeaning
X-RateLimit-Limit-MinuteAlways 6000.
X-RateLimit-Remaining-MinuteRequests left in the current 60-second window.
X-RateLimit-Limit-DayAlways 1000000.
X-RateLimit-Remaining-DayRequests left in the current 24-hour window.

Over-budget requests return 429 too_many_requests_per_minute or too_many_requests_per_day plus a Retry-After header (seconds until the next slot frees up). The cap sits at our upstream provider’s ceiling (~100 req/sec). It’s there to stop a leaked or runaway key from exhausting our budget, not to throttle real usage.

Webhooks

Webhooks are live. Subscribe via the Webhooks page and read the webhook contract docs for signing, retries, redelivery, and the auto-disable threshold.

Events available: batch.completed, batch.failed.

OpenAPI spec

Machine-readable spec available at GET /v1/openapi.json (public, cached 5 minutes). Use it to generate client SDKs (openapi-typescript-codegen, openapi-generator, etc.). The spec is the source of truth for the contract; if it diverges from this page, the spec wins.