> For the complete documentation index, see [llms.txt](https://docs.toolkit.equs.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.toolkit.equs.ai/standards/issuance/configuration-attribute-provider.md).

# Configuration: Attribute Provider

Host an endpoint the platform calls at issuance to fetch claim values

Flows 2 and 3 don't store your claim values - they ask you for them at the moment of signing. The endpoint that answers is the **Attribute Provider**, and you host it.

The platform calls it once per issuance, synchronously, telling you **who** the credential is for and **which credential** is being issued. You reply with the claim values, or decline. If the call fails, that issuance fails - so this is a runtime dependency, not a one-time setup.

One endpoint serves your whole Tenant. It is configured **per Tenant, not per Issuer**, so every Issuer you own calls the same URL.

### The two OAuth clients

Two clients are involved, and they point in opposite directions:

| Client          | Registered at         | Grant                | Used for                                                                                                                      |
| --------------- | --------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Platform client | Your own OAuth server | `client_credentials` | The platform authenticating *to you* when it calls your Attribute Provider                                                    |
| Agent client    | The platform          | `client_credentials` | You authenticating *to the platform* for the registration call below - see [Authentication](/standards/api/authentication.md) |

### 1. Implement the endpoint

#### Accept the platform's token

Create the **platform client** at your own OAuth server - whichever one issues the tokens your endpoint accepts - with the `client_credentials` grant. Note its `clientId`, `clientSecret` and token endpoint URL: you hand these over in step 2.

#### The call

```http
POST {endpointUrl}
Authorization: Bearer {token from your token endpoint}
Content-Type: application/json
Accept: application/json
x-request-id: 01JBQ7ZT4H8W3RXK2M5N9P6QVD

{
  "sub": "8f14e45f-ea1c-4b2e-9f77-3a1c0e2d5b90",
  "credentialIssuer": "https://ssi.example/api/v1/acme/iss/7c1f0a2e-…/oid4vci",
  "credentialConfigurationId": "Employee_Credential_9b2f6d31-…"
}
```

| Field                       | Use it to                                                                         |
| --------------------------- | --------------------------------------------------------------------------------- |
| `sub`                       | Look up the person the credential is for                                          |
| `credentialIssuer`          | Resolve the issuer metadata - the only field you need to discover everything else |
| `credentialConfigurationId` | Decide which credential is being issued, and echo it back in your response        |

Log the `x-request-id` header: it lets your logs be lined up with ours when something goes wrong.

#### Find the claim definitions

The issuer metadata is the contract. Resolve it from `credentialIssuer` by inserting the well-known segment **between the host and the path**, not by appending it:

```javascript
const metadataUrl = (credentialIssuer) => {
  const { origin, pathname } = new URL(credentialIssuer);
  return `${origin}/.well-known/openid-credential-issuer${pathname}`;
};
```

Look up your `credentialConfigurationId` in `credential_configurations_supported`. Each claim declares the `path` its value must occupy, whether it is `mandatory`, and its `dataType`:

```json
"credential_configurations_supported": {
  "Employee_Credential_9b2f6d31-…": {
    "format": "dc+sd-jwt",
    "credential_metadata": {
      "claims": [
        { "path": ["email"],                  "mandatory": true,  "dataType": "string" },
        { "path": ["employee", "id"],         "mandatory": true,  "dataType": "string" },
        { "path": ["employee", "department"], "mandatory": false, "dataType": "string" }
      ]
    }
  }
}
```

Cache this document and refresh it periodically. It changes only when an Issuer or one of its Schemas is edited.

#### Build the response

The response is an envelope:

```json
// 200 - you have the data
{
  "status": "ok",
  "credentialConfigurationId": "Employee_Credential_9b2f6d31-…",
  "claims": { "email": "…", "employee": { "id": "…", "department": "…" } }
}

// 200 - this subject is not entitled to this credential
{ "status": "denied", "reason": "not_eligible" }
```

Five rules decide whether it's accepted:

* **Echo `credentialConfigurationId`.** A mismatch is refused, so a routing bug on your side can't get one credential's data signed into another.
* **Nest claims exactly as the `path` arrays declare.** A value at the wrong depth is read as absent, and a missing mandatory claim fails issuance. Declared `dataType`s are enforced.
* **Return the declared subset.** Extra fields are ignored - harmless, but disclosure for nothing.
* **An empty `claims` object is a denial**, even with `"status": "ok"`.
* **Don't send `{"status": "pending"}`.** It's reserved for deferred issuance and rejected today.

Denial reasons are logged and never relayed to the wallet, so `reason` is for your diagnostics, not the Holder's screen.

#### Limits and failure handling

| Constraint            | Value     |
| --------------------- | --------- |
| Response deadline     | 5 seconds |
| Maximum response size | 50 kB     |

| You answer                                                                           | The Holder sees                                                  |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| `ok` with claims that satisfy the schema                                             | the credential                                                   |
| `denied`, empty `claims`, empty body, `404`, `204`                                   | `credential_request_denied`                                      |
| `401`, `403`                                                                         | `server_error` - the platform client's credentials were rejected |
| `5xx`, timeout, connection failure                                                   | `server_error`                                                   |
| unparseable, oversized, unknown `status`, `pending`, claims contradicting the schema | `server_error` or `invalid_credential_request`                   |
| `ok` echoing a different configuration                                               | `server_error`                                                   |

An outage is never reported as a denial: the Holder can do nothing about your endpoint being down.

#### A minimal implementation

```javascript
app.post("/attributes", requirePlatformToken, async (req, res) => {
  const { sub, credentialIssuer, credentialConfigurationId } = req.body;

  const config = await getCachedConfig(credentialIssuer, credentialConfigurationId);
  if (!config) return res.json({ status: "denied", reason: "unknown_configuration" });

  const person = await lookupPerson(sub);
  if (!person?.entitledTo(credentialConfigurationId)) {
    return res.json({ status: "denied", reason: "not_eligible" });
  }

  // build only the paths the schema declares, nested exactly as declared
  const claims = buildClaims(config.credential_metadata.claims, person);
  res.json({ status: "ok", credentialConfigurationId, claims });
});
```

If your own lookup can be slow, cache rather than risk the 5-second deadline - a timeout is a `server_error`, not a denial.

### 2. Register the endpoint

One call points the platform at your endpoint and hands over the platform client from step 1. Authenticate with your agent client.

{% hint style="info" icon="rectangle-api" %}
All calls mentioned on this page are on the [Control API](https://docs.toolkit.equs.ai/standards/issuance/pages/MvAvsnLQU3ER2hOIIDU2#id-2.4.1-apis).
{% endhint %}

## Upsert My Claims Integration

> Creates or replaces the claims integration for the calling Tenant — the claims webhooks and the Attribute Provider, under one OAuth2 client. Replaces rather than merges: an omitted leg is removed. One integration serves every issuer this Tenant owns.

```json
{"openapi":"3.0.0","info":{"title":"EqusDTK Control API Documentation","version":"0.1.2"},"tags":[{"name":"Tenant","description":""}],"security":[{"tenant":[]}],"components":{"securitySchemes":{"tenant":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Tenant. Token of a Tenant machine client or of a Tenant portal user, carrying the `tenantId` claim. Authorize one bearer scheme at a time."}},"schemas":{"UpdateTenantClaimsIntegrationRequest":{"type":"object","properties":{"client":{"description":"OAuth client credentials used to authenticate the integration calls.","allOf":[{"$ref":"#/components/schemas/ClaimsIntegrationClient"}]},"webhook":{"description":"Endpoints of the tenant's service notified about claim changes.","allOf":[{"$ref":"#/components/schemas/ClaimsIntegrationWebhook"}]},"attributeProvider":{"description":"Endpoint of the tenant's service queried for claims during issuance.","allOf":[{"$ref":"#/components/schemas/ClaimsIntegrationAttributeProvider"}]}},"required":["client"]},"ClaimsIntegrationClient":{"type":"object","properties":{"tokenUrl":{"type":"string","description":"Token endpoint used to obtain an access token for the integration calls."},"clientId":{"type":"string","description":"Client unique ID"},"clientSecret":{"type":"string","description":"OAuth client secret used to authenticate the integration calls.","writeOnly":true}},"required":["tokenUrl","clientId","clientSecret"]},"ClaimsIntegrationWebhook":{"type":"object","properties":{"issuedClaimsUrl":{"type":"string","description":"URL notified when new claims are issued."},"revokedClaimsUrl":{"type":"string","description":"URL notified when claims are revoked."}},"required":["issuedClaimsUrl","revokedClaimsUrl"]},"ClaimsIntegrationAttributeProvider":{"type":"object","properties":{"endpointUrl":{"type":"string","description":"URL called during issuance to fetch the claims of a subject."}},"required":["endpointUrl"]},"UpdateTenantClaimsIntegrationResponse":{"type":"object","properties":{"uuid":{"type":"string","description":"Claims integration unique ID"},"client":{"description":"OAuth client credentials used to authenticate the integration calls.","allOf":[{"$ref":"#/components/schemas/ClaimsIntegrationClientResponse"}]},"webhook":{"description":"Endpoints of the tenant's service notified about claim changes.","nullable":true,"type":"object","allOf":[{"$ref":"#/components/schemas/ClaimsIntegrationWebhookResponse"}]},"attributeProvider":{"description":"Endpoint of the tenant's service queried for claims during issuance.","nullable":true,"type":"object","allOf":[{"$ref":"#/components/schemas/ClaimsIntegrationAttributeProviderResponse"}]},"createdAt":{"format":"date-time","type":"string","description":"Date and time the claims integration was created."},"updatedAt":{"format":"date-time","type":"string","description":"Date and time the claims integration was last updated."}},"required":["uuid","client","createdAt","updatedAt"]},"ClaimsIntegrationClientResponse":{"type":"object","properties":{"tokenUrl":{"type":"string","description":"Token endpoint used to obtain an access token for the integration calls."},"clientId":{"type":"string","description":"Client unique ID"},"clientSecretSet":{"type":"boolean","description":"Whether a client secret is stored for this integration."}},"required":["tokenUrl","clientId","clientSecretSet"]},"ClaimsIntegrationWebhookResponse":{"type":"object","properties":{"issuedClaimsUrl":{"type":"string","description":"URL notified when new claims are issued."},"revokedClaimsUrl":{"type":"string","description":"URL notified when claims are revoked."}},"required":["issuedClaimsUrl","revokedClaimsUrl"]},"ClaimsIntegrationAttributeProviderResponse":{"type":"object","properties":{"endpointUrl":{"type":"string","description":"URL called during issuance to fetch the claims of a subject."}},"required":["endpointUrl"]}}},"paths":{"/api/v1/tenants/me/claims-integration":{"put":{"description":"Creates or replaces the claims integration for the calling Tenant — the claims webhooks and the Attribute Provider, under one OAuth2 client. Replaces rather than merges: an omitted leg is removed. One integration serves every issuer this Tenant owns.","operationId":"TenantController_upsertMyClaimsIntegration_api/v1","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTenantClaimsIntegrationRequest"}}}},"responses":{"200":{"description":"When the Tenant claims integration is created or replaced.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTenantClaimsIntegrationResponse"}}}},"400":{"description":"When neither webhook nor attributeProvider is provided, when the webhook leg is sent without the client block, or when the Attribute Provider URL is not a public https URL."},"404":{"description":"When Tenant identified by id does not exist."}},"summary":"Upsert My Claims Integration","tags":["Tenant"]}}}}
```

* **It replaces the whole integration.** An omitted leg is removed: send only `attributeProvider` and any configured webhooks are deleted.
* `endpointUrl` must be public HTTPS.
* The client secret is write-only - you can overwrite it, but not read it back.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.toolkit.equs.ai/standards/issuance/configuration-attribute-provider.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
