# 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.

## Base URL [#base-url]

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

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

Partner integrations and keys are issued by eProspera administrators. They are not self-service.

* 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.
* 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.

Send the key on every request:

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

## 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`                 |
| `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.

## 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.
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. A legally relevant PATCH 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`.

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

The embed is an alternative to the portal handoff. Session issuance is accepted only after application data, proof of address, and payment are complete. An approved Veriff result and a still-valid AOC may be reused. When both steps are complete, eProspera automatically moves the application to `Pending Review`; do not call `/submit` for this flow.

Before requesting a session, generate a PKCE verifier in the applicant's browser and send only its S256 challenge through your backend:

```ts
const base64url = (bytes: Uint8Array) =>
  btoa(String.fromCharCode(...bytes))
    .replaceAll('+', '-')
    .replaceAll('/', '_')
    .replace(/=+$/, '');

const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const codeVerifier = base64url(verifierBytes);
const digest = await crypto.subtle.digest(
  'SHA-256',
  new TextEncoder().encode(codeVerifier),
);
const codeChallenge = base64url(new Uint8Array(digest));
```

Your backend calls the embed-session endpoint with the exact parent origin:

```json
{
  "parentOrigin": "https://apply.partner.example",
  "codeChallenge": "<43-character-base64url-S256-challenge>",
  "codeChallengeMethod": "S256",
  "locale": "en"
}
```

Load the returned one-time URL with camera/microphone delegation:

```html
<iframe
  id="eprospera-embed"
  allow="camera; microphone; fullscreen"
  referrerpolicy="strict-origin"
></iframe>
```

Install the message listener before assigning `src`, otherwise a fast `SESSION_READY` event can be missed. The link expires after 10 minutes. On `SESSION_READY`, send the verifier directly from the browser to the frame; it must never pass through logs or analytics:

```ts
const frame = document.querySelector<HTMLIFrameElement>('#eprospera-embed')!;
const embedUrl = sessionResponse.data.embedUrl;
const embedOrigin = new URL(embedUrl).origin;

window.addEventListener('message', (message) => {
  if (
    message.origin !== embedOrigin ||
    message.source !== frame.contentWindow
  ) {
    return;
  }

  if (
    message.data?.source === 'eprospera.embed' &&
    message.data?.version === 1 &&
    message.data?.event === 'SESSION_READY'
  ) {
    frame.contentWindow!.postMessage(
      {
        source: 'eprospera.partner',
        version: 1,
        event: 'ACTIVATE',
        codeVerifier,
      },
      embedOrigin,
    );
  }
});

frame.src = embedUrl;
```

After activation, the iframe keeps a non-extractable signing key only in memory. Reloading destroys that key, so your backend must issue a replacement link. The active flow lasts two hours.

Every outbound event has the same versioned, status-only shape:

```ts
type EProsperaEmbedEventV1 = {
  source: 'eprospera.embed';
  version: 1;
  event:
    | 'SESSION_READY'
    | 'AGREEMENT_SIGNED'
    | 'KYC_STARTED'
    | 'KYC_COMPLETED'
    | 'KYC_FAILED'
    | 'APPLICATION_SUBMITTED'
    | 'SESSION_EXPIRED';
  state: {
    agreement: 'pending' | 'signed';
    kyc: 'not_started' | 'started' | 'pending' | 'completed' | 'failed';
    submission: 'waiting' | 'submitting' | 'submitted';
  };
};
```

Validate `message.origin`, `message.source`, `source`, `version`, `event`, and every state enum before consuming any event. Do not treat Veriff SDK completion as approval: `KYC_COMPLETED` is emitted only after eProspera persists Veriff's signed decision webhook. If the nested flow is incompatible with the applicant's browser, let the applicant open Veriff in a user-initiated top-level tab; keep the eProspera iframe mounted so it can continue status polling.

## 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`.

## Response fields [#response-fields]

Application responses include:

| Field                                                    | Meaning                                                          |
| -------------------------------------------------------- | ---------------------------------------------------------------- |
| `version`                                                | Integer used as the next PATCH `expectedVersion`                 |
| `applicationData`                                        | Curated application data                                         |
| `applicationData.personalDetails.proofOfAddressUploadId` | Opaque private-upload ID, when an upload is attached             |
| `applicantPortalAccess`                                  | Claim/login metadata; never contains credentials or a Veriff URL |
| `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 portal 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.

Application lists use cursor pagination:

```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.

## 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`.
* A duplicate received while the original is processing returns `409`.

## 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               |
| `429`  | Rate limit exceeded; use `Retry-After`                          |

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