# Partner Keys (/partner-keys)

Partner Keys let an approved organization create and manage natural-person residency applications over REST. Each `pk-...` key belongs to one Partner Integration and can access only that integration's applications.



The actors have separate responsibilities: the partner manages application data, private proof uploads, and payment. The applicant personally accepts the Agreement of Coexistence (AOC) and completes Veriff either in the eProspera portal or in the cross-origin eProspera embed. Partners cannot submit a signature, receive a Veriff URL, or read Veriff documents or decisions through the Partner API.

This page is the API contract. Provisioning, origins, key rotation, and the pre-production checklist are in [Partner onboarding](/partner-onboarding). The applicant-facing iframe is in [Embedded agreement and identity verification](/partner-embed).

## Base URL [#base-url]

| Environment | Origin                             |
| ----------- | ---------------------------------- |
| Production  | `https://portal.eprospera.com`     |
| Staging     | `https://stg.portal.eprospera.com` |

For the legacy staging hostname and OAuth discovery behavior, see
[Testing in staging](/testing-in-staging).

## Provisioning and key lifecycle [#provisioning-and-key-lifecycle]

Partner integrations and keys are issued by eProspera administrators. They are not self-service. See [Partner onboarding](/partner-onboarding) for what to send and how keys rotate.

* The raw key is displayed once. eProspera stores only its SHA-256 hash.
* Keys expire after 90 days.
* Issuing a replacement key shortens older active keys to a maximum seven-day overlap.
* Scopes are fixed at issuance. Changing scopes means issuing a replacement key.
* A key can be revoked immediately. Integrations can be suspended or revoked independently.
* Authentication is rejected when the key is expired or revoked, the integration is not active, or its legal entity is dissolved.
* Redirect and checkout return URLs must use an HTTPS origin approved on the integration. Embed parent origins use a separate approved list.

Send the key on every request:

```text
Authorization: Bearer pk-...
```

Authentication failures return one of these bodies: `Missing API key` (`401`), `Invalid API key` (`401`, also used for expired and revoked keys and inactive integrations), `Insufficient permissions` (`403`), and `Too many requests` (`429`).

## Scopes [#scopes]

| Scope                                 | Capability                                                        |
| ------------------------------------- | ----------------------------------------------------------------- |
| `partner:person.application.read`     | List and read this integration's applications and private uploads |
| `partner:person.application.create`   | Create applications and upload proof of address                   |
| `partner:person.application.update`   | Update Draft applications and upload proof of address             |
| `partner:person.application.pay`      | Create checkout sessions and apply voucher payments               |
| `partner:person.application.submit`   | Submit a completed application                                    |
| `partner:person.embed_session.create` | Create a PKCE-bound applicant AOC + KYC embed session             |

There is no API scope for signing the Agreement of Coexistence. Applicant consent always occurs in an eProspera-hosted origin, including when that origin is framed by an approved partner page.

## Endpoints [#endpoints]

| Method  | Path                                                           | Required scope                                      |
| ------- | -------------------------------------------------------------- | --------------------------------------------------- |
| `POST`  | `/api/v1/partner/residency_applications`                       | `partner:person.application.create`                 |
| `GET`   | `/api/v1/partner/residency_applications`                       | `partner:person.application.read`                   |
| `GET`   | `/api/v1/partner/residency_applications/{id}`                  | `partner:person.application.read`                   |
| `PATCH` | `/api/v1/partner/residency_applications/{id}`                  | `partner:person.application.update`                 |
| `GET`   | `/api/v1/partner/residency_applications/{id}/documents`        | `partner:person.application.read`                   |
| `POST`  | `/api/v1/uploads/proof_of_address`                             | `create` or `update`                                |
| `GET`   | `/api/v1/uploads/proof_of_address/{uploadId}`                  | `read`, `create`, or `update`                       |
| `POST`  | `/api/v1/partner/residency_applications/{id}/checkout_session` | `partner:person.application.pay`                    |
| `POST`  | `/api/v1/partner/residency_applications/{id}/pay/voucher`      | `partner:person.application.pay`                    |
| `POST`  | `/api/v1/partner/residency_applications/{id}/pay/coupon`       | `partner:person.application.pay` (deprecated alias) |
| `POST`  | `/api/v1/partner/residency_applications/{id}/submit`           | `partner:person.application.submit`                 |
| `POST`  | `/api/v1/partner/residency_applications/{id}/embed_sessions`   | `partner:person.embed_session.create`               |

Cross-partner reads and mutations return `404` so the existence of another integration's resources is not disclosed.

The documents endpoint returns the Agreement of Coexistence version the applicant accepted, with its template PDF, and, once the residency exists, the applicant's signed agreement and policy documents. Documents are classified by stable slug only; it never returns identity-verification artifacts, tax documents, or other uploads.

The [Personal and applicant documents guide](/personal-and-applicant-documents) covers
complete responses, acceptance versus signed PDFs, delayed generation, download handling,
and the exact residency-matching rules.

## Secure application flow [#secure-application-flow]

### Partner and applicant responsibilities [#partner-and-applicant-responsibilities]

| Stage                 | Partner through the Partner API                                                                  | Applicant inside eProspera                                         |
| --------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| Application           | Creates the Draft and updates data using `expectedVersion`                                       | —                                                                  |
| Proof and payment     | Uploads private proof of address and completes checkout or voucher payment                       | —                                                                  |
| Account access        | Reads `applicantPortalAccess` status only; never receives the claim token                        | Claims the account from the 24-hour email and logs in              |
| AOC acceptance        | Fetches and delivers the current `agreementUrl`; **the partner cannot sign**                     | Authenticates as the application owner and signs only on eProspera |
| Veriff                | Reads `verificationRequired` and `submitReady` only; **cannot initiate Veriff or view its data** | Starts and completes Veriff in the authenticated portal            |
| Submission and status | Submits when `submitReady` is `true`, then polls application status                              | —                                                                  |

1. Create a Draft with `POST /partner/residency_applications` and a unique `Idempotency-Key`. eProspera sends the applicant a single-use account-claim link that expires after 24 hours.
2. Choose one proof path: upload a document using the new application ID, or send `applicationData.personalDetails.proofOfAddressSwornStatement` inline on create/PATCH.
3. For the upload path only, attach the returned opaque `proofOfAddressUploadId` with `PATCH /partner/residency_applications/{id}` and the latest `expectedVersion`. The sworn-statement path skips upload and attachment.
4. Pay with a checkout session or voucher. Each payment request requires its own `Idempotency-Key`. In staging, voucher `API1234` applies to every product; hosted Stripe checkout accepts test card `ACCT-000015`. See [Testing in staging](/testing-in-staging).
5. Instruct the applicant to use the claim email and log in to eProspera. Fetch the application again after the final PATCH, then send that applicant the current `nextSteps.agreementUrl`.
6. The agreement URL contains a 30-minute, single-use token. eProspera requires the applicant's Auth0 login and verifies that the logged-in subject owns the application. During login, the bearer is held server-side behind a short-lived, single-use opaque continuation rather than copied into login or `returnTo` URLs. A different user receives `404`, and admin impersonation is not accepted as applicant identity.
7. After acceptance, fetch the application again: acceptance consumes the link and increments `version`. Use the refreshed version for any later PATCH. When the application carried an approved `redirectUrl`, the applicant is returned to it after accepting.
8. The logged-in applicant starts and completes Veriff in the authenticated eProspera portal. The partner sees only `applicantPortalAccess`, `nextSteps.verificationRequired`, and `nextSteps.submitReady`.
9. Submit the application after `nextSteps.submitReady` becomes `true`.

The hosted agreement page selects the applicable AOC server-side. Acceptance records the agreement ID and version, SHA-256 hashes of its content and the legally relevant application data, the authenticated applicant's Auth0 subject, signer name, signature image, timestamp, IP address, user agent, and access-token ID. Any PATCH that changes `applicationData` invalidates the current acceptance and returns a new `agreementUrl`; submission also rejects consent if application data changed through another path. Bearer-only acceptances without a matching authenticated signer are not valid for submission.

Submission requires current agreement acceptance, proof of address, a paid invoice, and approved identity verification. AOC acceptance and payment endpoints never submit the application; submission occurs only through `/submit` with `partner:person.application.submit`.

### What the applicant receives [#what-the-applicant-receives]

On creation, an applicant without an existing eProspera password receives an email titled **Claim your ePróspera account** with a **Set Password** button. The link is single-use and expires after 24 hours; if it expires, the applicant asks eProspera support for a new one. Applicants who already have an eProspera account receive no email and log in with their existing credentials; in that case `applicantPortalAccess.claimLinkSent` is `false` on the create response. A PATCH that changes the applicant email sends a new claim email to the new address under the same rule.

## Embedded applicant flow [#embedded-applicant-flow]

The embed is an alternative to the portal handoff. Your page frames an eProspera-hosted iframe in which the applicant signs the AOC and completes Veriff without first claiming a portal account. Your page receives status-only `postMessage` events, and eProspera submits the application automatically when both steps complete. Session issuance requires `partner:person.embed_session.create` and an application whose data, proof of address, and payment are complete.

The full guide, including origins, activation, the event contract, expiry, and the error catalog, is [Embedded agreement and identity verification](/partner-embed).

## Create and update contracts [#create-and-update-contracts]

Create requests require `Idempotency-Key`:

```bash
curl -X POST https://portal.eprospera.com/api/v1/partner/residency_applications \
  -H "Authorization: Bearer pk-REDACTED" \
  -H "Idempotency-Key: application-ada-20260716" \
  -H "Content-Type: application/json" \
  -d '{
    "applicationData": {
      "residencyType": "e_resident",
      "personalDetails": {
        "givenName": "Ada",
        "surname": "Lovelace",
        "email": "applicant@example.com",
        "dateOfBirth": "1990-12-10",
        "sex": "female",
        "countryOfBirth": "GB",
        "citizenships": ["GB"],
        "alternateNamesAttestation": false,
        "criminalBackgroundAttestation": true,
        "address": {
          "line1": "123 Main St",
          "city": "London",
          "postalCode": "SW1A 1AA",
          "country": "GB"
        },
        "phoneNumber": "+447700900123"
      }
    },
    "redirectUrl": "https://partner.example/return"
  }'
```

An update sends the full application DTO plus the response's latest `version`:

```json
{
  "expectedVersion": 3,
  "applicationData": {
    "residencyType": "e_resident",
    "personalDetails": {
      "givenName": "Ada",
      "surname": "Lovelace",
      "email": "applicant@example.com",
      "dateOfBirth": "1990-12-10",
      "sex": "female",
      "countryOfBirth": "GB",
      "citizenships": ["GB"],
      "alternateNamesAttestation": false,
      "criminalBackgroundAttestation": true,
      "proofOfAddressUploadId": "12345678-abcd-4abc-8abc-123456789012",
      "address": {
        "line1": "123 Main St",
        "city": "London",
        "postalCode": "SW1A 1AA",
        "country": "GB"
      },
      "phoneNumber": "+447700900123"
    }
  }
}
```

Once an invoice is paid, the residency type and applicant email are locked. A stale `expectedVersion` returns `409` with `currentVersion`.

### Residency types [#residency-types]

| `residencyType`      | Product                                                                                             |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| `limited_e_resident` | Limited e-Residency                                                                                 |
| `e_resident`         | e-Residency                                                                                         |
| `resident_annual`    | Full residency, billed annually. The invoice also includes the mandatory resident insurance product |

Current prices appear on the invoice created for the application and on the hosted checkout page.

### Field formats [#field-formats]

| Field                                                       | Format                                                                                                                                         |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `dateOfBirth`                                               | `YYYY-MM-DD`                                                                                                                                   |
| `sex`                                                       | `male` or `female`                                                                                                                             |
| `countryOfBirth`, `citizenships[]`, `address.country`       | Uppercase ISO 3166-1 alpha-2. An unknown code returns `400` with `Unknown country code(s): XX. Use ISO 3166-1 alpha-2 country codes.`          |
| `citizenships`                                              | At least one entry                                                                                                                             |
| `phoneNumber`                                               | E.164 with leading `+`, 8 to 15 digits                                                                                                         |
| `alternateNames`                                            | Optional free text when `alternateNamesAttestation` is `true`                                                                                  |
| `address.line2`, `address.state`                            | Optional                                                                                                                                       |
| `proofOfAddressSwornStatement`                              | `{ "agreed": true, "signature": "data:image/png;base64,...", "locale": "en" \| "es" }`. Send either this or `proofOfAddressUploadId`, not both |
| `information.residencyReason`, `information.referralSource` | Optional free text                                                                                                                             |
| `referralCode`                                              | Optional. Attributes the applicant to an eProspera referral code; see below                                                                    |
| `redirectUrl`                                               | Optional HTTPS URL on an approved redirect origin. Used to return the applicant after hosted AOC acceptance                                    |

### Referral codes [#referral-codes]

`referralCode` on create or PATCH attributes the applicant to an existing eProspera referral code. Attribution is permanent and only possible for newly created applicants.

| Status | Body `error`                                                                                  | Cause                                        |
| ------ | --------------------------------------------------------------------------------------------- | -------------------------------------------- |
| `400`  | `Referral code not found.`                                                                    | Unknown code                                 |
| `400`  | `Self-referral is not allowed for this referral code.`                                        | The code belongs to the applicant            |
| `409`  | `This applicant is already attributed to a different referral code and cannot be reassigned.` | The applicant already has a referral         |
| `409`  | `Referral codes can only be applied to users created within the last hour.`                   | The applicant account is older than one hour |

### Create and update errors [#create-and-update-errors]

| Status | Body `error`                                                   | Cause                                                                         |
| ------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `400`  | `Invalid JSON`                                                 | Body is not valid JSON                                                        |
| `400`  | `Invalid request body` with `details`                          | Schema validation failed; `details` lists the Zod issues                      |
| `400`  | `redirectUrl origin is not allowed for this integration`       | `redirectUrl` is not on an approved redirect origin                           |
| `400`  | `Residency application is not in draft status`                 | PATCH after submission                                                        |
| `400`  | `You cannot change the residency type after payment.`          | PATCH changed `residencyType` on a paid application                           |
| `400`  | `You cannot change the applicant email after payment.`         | PATCH changed `personalDetails.email` on a paid application                   |
| `404`  | `Residency application not found`                              | Unknown ID or another integration's application                               |
| `404`  | `Proof-of-address upload not found for this application.`      | `proofOfAddressUploadId` does not belong to this application                  |
| `409`  | `The application changed. Fetch the latest version and retry.` | Stale `expectedVersion`; the body also carries `currentVersion`               |
| `409`  | `Applicant identity requires manual account linking.`          | The applicant email matches an ambiguous existing identity. Contact eProspera |

## Proof-of-address uploads [#proof-of-address-uploads]

`POST /api/v1/uploads/proof_of_address` is `multipart/form-data` with two fields: `applicationId` (a Draft owned by your integration) and `file`. Accepted types are PDF, JPEG, and PNG. The declared MIME type must match the file's actual bytes, and the maximum size is 4.5 MB. The response is `{ "data": { "id", "applicationId", "contentType", "size" } }` and never contains a URL. Attach the `id` through PATCH as `proofOfAddressUploadId`.

`GET /api/v1/uploads/proof_of_address/{uploadId}` streams the file back with its original `Content-Type`, `Content-Disposition: inline`, and `Cache-Control: private, no-store`.

| Status | Body `error`                                                        | Cause                                                       |
| ------ | ------------------------------------------------------------------- | ----------------------------------------------------------- |
| `400`  | `applicationId is required` / `No file provided`                    | Missing form field                                          |
| `400`  | `Unsupported file type. Only PDF, JPEG, and PNG files are allowed.` | Wrong type, or declared type does not match the bytes       |
| `400`  | `File size exceeds 4.5MB limit`                                     | File field larger than 4.5 MB                               |
| `413`  | `File size exceeds 4.5MB limit`                                     | Whole request body larger than the limit                    |
| `404`  | `Residency application not found` / `Upload not found`              | Not a Draft owned by your integration, or unknown upload ID |

## Payment [#payment]

Both payment endpoints require the application to be in `Draft` with proof of address present, and each call requires its own `Idempotency-Key`. Payment never submits the application.

### Hosted checkout [#hosted-checkout]

Send exactly one of `paymentProvider` or `paymentMethod`, plus a `redirectUrl` on an approved redirect origin and an optional `email` for the receipt.

| `paymentProvider` | Checkout                                                                                |
| ----------------- | --------------------------------------------------------------------------------------- |
| `stripe`          | Stripe hosted checkout, cards                                                           |
| `stripe-crypto`   | Stripe hosted checkout, crypto payment method                                           |
| `blink`           | Bitcoin Lightning through Blink; the returned `url` is an eProspera-hosted payment page |
| `blink-onchain`   | Bitcoin on-chain through Blink                                                          |
| `solana-pay-ptc`  | Solana Pay; the returned `url` is an eProspera-hosted payment page                      |

The response is `{ "data": { "url": "..." }, "invoiceId": "..." }`. Send the applicant or payer to `data.url`. Stripe returns the payer to `redirectUrl?success=true` after payment and to `redirectUrl` unchanged on cancel. Payment is confirmed asynchronously: poll the application until `nextSteps.paymentRequired` is `false` rather than trusting the redirect.

| Status | Body `error`                                                                                            | Cause                                                            |
| ------ | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `400`  | `Please provide proof of address before continuing.`                                                    | Attach proof of address first                                    |
| `400`  | `Residency application invoice is already paid`                                                         | Nothing to pay                                                   |
| `400`  | `Residency application invoice is not open for payment`                                                 | The invoice is in a state that cannot be paid; contact eProspera |
| `503`  | `Blink checkout is temporarily unavailable` with `code: blink_checkout_disabled` and `Retry-After: 120` | Blink is disabled; use another provider or retry later           |

### Vouchers [#vouchers]

`POST .../pay/voucher` with `{ "voucherCode": "..." }` applies a voucher that must cover the full invoice. On success the response is `{ "data": <application> }`, with `nextSteps.paymentRequired` already `false`. Errors carry a machine-readable `code`:

| Status | `code`                      | Body `error`                                          |
| ------ | --------------------------- | ----------------------------------------------------- |
| `400`  | `voucher_not_found`         | `Voucher code is invalid`                             |
| `400`  | `voucher_expired`           | `Voucher code has expired`                            |
| `409`  | `voucher_already_redeemed`  | `Voucher code has already been redeemed`              |
| `400`  | `voucher_not_applicable`    | `Voucher code does not apply to this invoice`         |
| `400`  | `voucher_not_full_coverage` | `Voucher code does not cover the full invoice amount` |
| `404`  | `invoice_not_found`         | `Residency application invoice not found`             |

## Submission [#submission]

`POST .../submit` takes no body and no `Idempotency-Key`. It moves the application from `Draft` to `Pending Review` and returns the application. It fails with `400` and one of these messages while a prerequisite is missing: `Please accept the current Agreement of Coexistence before continuing.`, `Please provide proof of address before continuing.`, a payment message containing `invoice`, or `Please complete identity verification before continuing.` An already submitted application returns `400 Residency application is not in draft status`.

## Response fields [#response-fields]

Application responses include:

| Field                                                    | Meaning                                                                                      |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `id`                                                     | Application UUID                                                                             |
| `version`                                                | Integer used as the next PATCH `expectedVersion`                                             |
| `statusId`                                               | `Draft`, `Pending Review`, `Approved`, or `Rejected`                                         |
| `submittedAt`, `approvedAt`, `rejectedAt`                | Timestamps for the corresponding transitions, otherwise `null`                               |
| `residentPermitNumber`                                   | The applicant's 14-digit RPN once approved, otherwise `null`                                 |
| `referralCode`                                           | The attributed referral code, if any                                                         |
| `applicationData`                                        | Curated application data                                                                     |
| `applicationData.residencyType`                          | One of the public residency type codes above                                                 |
| `applicationData.aoc`                                    | `aocId`, `signed`, `signedByName`, `signedAt`, `agentAuthorized`. Read-only evidence summary |
| `applicationData.personalDetails.proofOfAddressUploadId` | Opaque private-upload ID, when an upload is attached                                         |
| `applicantPortalAccess.email`                            | Portal login email                                                                           |
| `applicantPortalAccess.hasLoggedIn`, `lastLogin`         | Whether and when the applicant has logged in to the portal                                   |
| `applicantPortalAccess.claimLinkSent`, `claimLinkSentAt` | Whether the current mutation sent a claim email                                              |
| `nextSteps.agreementUrl`                                 | Hosted consent URL while current acceptance is required                                      |
| `nextSteps.proofOfAddressRequired`                       | Whether proof is still missing                                                               |
| `nextSteps.paymentRequired`                              | Whether payment is still required                                                            |
| `nextSteps.verificationRequired`                         | Whether identity verification is still required                                              |
| `nextSteps.verificationInstructions`                     | Applicant-facing portal verification guidance, when required                                 |
| `nextSteps.submitReady`                                  | Whether all submission prerequisites currently pass                                          |

`nextSteps.agreementUrl` is ephemeral. Fetch the application after every PATCH and after AOC acceptance. Do not cache or reuse an older URL or `version`.

`applicantPortalAccess.claimLinkSent` and `claimLinkSentAt` describe whether the current mutation enqueued a claim email. GET requests do not send email and therefore return `false` and `null` for those fields.

### Status lifecycle [#status-lifecycle]

Applications move `Draft` → `Pending Review` → `Approved` or `Rejected`. After submission, poll `GET /partner/residency_applications/{id}` until `statusId` leaves `Pending Review`; `residentPermitNumber` is populated on approval. eProspera reviews submitted applications manually, so allow for business-day turnaround in production.

Application lists use cursor pagination and return the newest applications first:

```text
GET /api/v1/partner/residency_applications?limit=50&cursor=<application-id>
```

`limit` defaults to 50 and cannot exceed 100. The response contains `pagination.nextCursor`, or `null` on the final page. Invalid values return `400 Invalid pagination parameters`.

## Idempotency [#idempotency]

Application creation, checkout creation, and voucher payment require an `Idempotency-Key` containing 8–128 URL-safe characters. Records are retained for 24 hours per integration and route.

* An identical completed request replays the stored response with `Idempotency-Replayed: true`.
* Reusing a key with different input returns `409 Idempotency-Key was already used with a different request body.`
* A duplicate received while the original is processing returns `409`.
* A missing or malformed key returns `400 Idempotency-Key is required and must contain 8-128 URL-safe characters.`

## Rate limits [#rate-limits]

Limits are enforced by both API key and integration:

| Class                  | Per key    | Per integration |
| ---------------------- | ---------- | --------------- |
| Reads                  | 120/minute | 300/minute      |
| Updates and submission | 60/minute  | 150/minute      |
| Application creation   | 10/minute  | 50/hour         |
| Embed session issuance | 10/minute  | 50/hour         |
| Uploads                | 20/minute  | 100/hour        |
| Payment and vouchers   | 5/minute   | 20/hour         |

Invalid credentials are limited to 20 attempts per minute per source IP. A throttled request returns `429` and a `Retry-After` header.

## Errors [#errors]

| Status | Meaning                                                         |
| ------ | --------------------------------------------------------------- |
| `400`  | Invalid request, disallowed redirect origin, or invalid state   |
| `401`  | Missing, invalid, expired, or revoked key; inactive integration |
| `403`  | Valid key without the required scope                            |
| `404`  | Resource missing or owned by another integration                |
| `409`  | Stale application version or idempotency conflict               |
| `413`  | Upload request body too large                                   |
| `429`  | Rate limit exceeded; use `Retry-After`                          |
| `503`  | A payment provider or the embed is disabled in this environment |

Every successful action, denied scoped action, throttling decision, inactive-key attempt, and admin key lifecycle change is durably audited without storing raw credentials.
