The member is created with the account (Create account). This API does not support adding another member to an account. There is no POST /api/v1/members/.
| Resource | Path | Purpose |
|---|---|---|
| Account members | /api/v1/accounts/{account_id}/members/ |
GET members on the account (Account members) |
| Members (collection) | /api/v1/members/ |
GET paginated profile summaries (List members) |
| Member | /api/v1/members/{member_id}/ |
GET full profile (Retrieve one member); PATCH birth fields (Update one member); DELETE one profile (Delete a member) |
| Profile attributes | /api/v1/members/{member_id}/names/ and the other nested routes |
Add or remove names, addresses, emails, phone numbers, usernames, and employers (Add or remove profile attributes) |
| Member products | /api/v1/members/{member_id}/products/ |
GET every product on the member (Member products) |
| Member product | /api/v1/members/{member_id}/product/ |
POST add an on-demand scan product (Add a product) |
| Subscription | /api/v1/members/{member_id}/subscriptions/{product_id}/ |
GET one subscription and its change history (Subscriptions) |
| Change subscription | /api/v1/members/{member_id}/change_subscription/{new_product_id}/ |
POST move the member to another subscription product (Change subscription) |
| Scan credits | /api/v1/members/{member_id}/credits/ |
GET credit balances (Scan credits) |
| Scan credit | /api/v1/members/{member_id}/credits/{product_id}/ |
GET one product’s credit balance (One product) |
| Add credits | /api/v1/members/{member_id}/credits/{product_id}/add/ |
POST assign credits from the org pool (Add credits) |
| Member scans | /api/v1/members/{member_id}/scans/ |
GET scan schedule, in-progress jobs, and completed scan history (Scans) |
| Scan one member | /api/v1/members/{member_id}/scan/{product_id}/ |
POST spend one credit of that product and queue a scan (Scan one member) |
| Scan many members | /api/v1/scan/ |
POST queue scans for many member ids or account ids (Scan many members) |
| Member exposures | /api/v1/members/{member_id}/exposures/ |
GET paginated exposures for one profile (Exposure API) |
Each object in an account’s members, each row from GET /api/v1/accounts/{account_id}/members/, and each row in GET /api/v1/members/ → results uses this summary.
| Field | Type | Description |
|---|---|---|
member_id |
integer | Profile (member) id |
primary_name |
string | Display name |
primary_email |
string | null | Primary email when set |
is_primary |
boolean | true for the primary member on the account. The member created with the account is true |
profile_threat_score |
number | null | Member-level threat score (Threat score API). Available only to select beta users. null otherwise |
account_id |
integer | Present on GET /api/v1/members/ only — the account this profile belongs to |
These fields are sent on POST /api/v1/accounts/ beside email and product_ids. email on that call is the account login. The fields below are the member profile. They are optional. Without a name, city, state, or birth year, scan and removal are severely limited.
Nested attribute POST bodies use the same shapes. The member is returned on account create (201).
{
"birth_year": 1980,
"names": [
{ "first_name": "Bob", "last_name": "Example", "no_middle_name": true }
],
"addresses": [
{ "city": "New York City", "state": "NY", "country": "US" }
],
"emails": [
{ "email": "bob@example.com" }
]
}
By sending an email or phone number, you agree you have verified that the end user owns it. In v1, Kanary does not provide phone or email verification endpoints.
Submit addresses that are already valid and acceptable for scanning.
emails — each item is { "email": "<address>" }:
| Rule | Detail |
|---|---|
| Validator | Must be a valid email address |
| Format | Must be a valid email address shape (e.g. user@example.com); not a display name, not a bare domain |
| Normalization | Stored lowercase in Kanary (local@Example.com → local@example.com) |
| Duplicates | Rejected if the same address already exists on that member |
phone_numbers — each item is { "phone_number": "<e164>" }:
| Rule | Detail |
|---|---|
| Validator | Must be a valid international phone number |
| Format | International / E.164: leading +, country code, then national number (e.g. +17015551234, +14155552671) |
| Validity | Must be a valid number for its region; national-only strings without country code (e.g. 7015551234) typically fail |
| Duplicates | Rejected if the same number already exists on that member |
"emails": [{ "email": "bob@example.com" }],
"phone_numbers": [{ "phone_number": "+17015551234" }]
{
"birth_year": 1980,
"birth_month": 6,
"birth_day": 15,
"names": [
{
"first_name": "Bob",
"last_name": "Example",
"middle_name": "Q",
"suffix": "Jr",
"no_middle_name": false,
"is_primary": true
}
],
"addresses": [
{
"street_address": "123 Main St",
"unit": "4B",
"city": "New York City",
"state": "NY",
"postal_code": "82230",
"country": "US",
"is_primary": true
}
],
"phone_numbers": [{ "phone_number": "+17015551234" }],
"emails": [{ "email": "bob@example.com" }],
"usernames": [{ "username": "bob_example" }],
"employers": [{ "employer": "Acme Corp", "industry": "Tech and Engineering" }]
}
400 Bad Request with field errors, for example:
| Input | Typical error |
|---|---|
| Invalid ISO country on address | country: must be valid ISO3166 alpha-2 |
US/GB address without state |
state: required for US/GB |
| Invalid US/GB state code | state: must be valid state/region code |
| Name < 2 characters | first_name / last_name validation |
Middle name missing when no_middle_name is false |
middle_name: required |
Both middle_name and no_middle_name: true |
conflicting middle name flags |
| Invalid email format | emails / email: Enter a valid email address |
| Duplicate email on member | Email already exists for member |
| Invalid or non-international phone | phone_numbers / phone_number: phonenumber validation error |
GET /api/v1/accounts/{account_id}/members/
Active members on this account. Each row is a member summary. account_id is on the envelope. See Pagination.
| Param | Default | Max | Description |
|---|---|---|---|
limit |
100 |
250 |
Members per page |
offset |
0 |
— | Starting index into the list |
GET /api/v1/accounts/12345/members/
200 OK){
"account_id": 12345,
"count": 1,
"next": null,
"previous": null,
"results": [
{
"member_id": 67890,
"primary_name": "Bob Example",
"primary_email": "bob@example.com",
"is_primary": true,
"profile_threat_score": 6.2
}
]
}
| Status | Condition |
|---|---|
400 |
Invalid limit or offset |
401 |
Missing or invalid API key |
403 |
Partner inactive |
404 |
Account not found |
GET /api/v1/members/
Returns a paginated list of member summaries. See Pagination.
| Param | Default | Max | Description |
|---|---|---|---|
limit |
100 |
250 |
Members per page (Pagination) |
offset |
0 |
— | Starting index into the member list |
GET /api/v1/members/?limit=100&offset=0
200 OK){
"count": 1,
"next": null,
"previous": null,
"results": [
{
"account_id": 22392,
"member_id": 67890,
"primary_name": "Bob Example",
"primary_email": "bob@example.com",
"is_primary": true,
"profile_threat_score": 6.2
}
]
}
| Status | Condition |
|---|---|
400 |
Invalid limit or offset |
401 |
Missing or invalid API key |
403 |
Partner inactive |
GET /api/v1/members/{member_id}/products/
Every product on the member. Active rows first, then terminated subscriptions and on-demand rows with credits_remaining 0. Same object as Product API. See Pagination.
| Param | Default | Max | Description |
|---|---|---|---|
limit |
100 |
250 |
Products per page |
offset |
0 |
— | Starting index into the sorted list |
GET /api/v1/members/70001/products/
200 OK){
"member_id": 70001,
"count": 2,
"next": null,
"previous": null,
"results": [
{
"product_id": 201,
"product_name": "Core Scan",
"description": "On-demand scan of the Core catalog.",
"charge_type": "on_demand",
"billing_frequency": "one_time",
"price_per_seat": 299,
"credits_remaining": 1
},
{
"product_id": 301,
"product_name": "Trial Scan",
"description": "On-demand scan of the trial catalog.",
"charge_type": "on_demand",
"billing_frequency": "one_time",
"price_per_seat": 499,
"credits_remaining": 0
}
]
}
| Status | Condition |
|---|---|
400 |
Invalid limit or offset |
401 |
Missing or invalid API key |
403 |
Partner inactive |
404 |
Member not found |
POST /api/v1/members/{member_id}/product/
A subscription product_id on this call returns 400 (error: use_subscription_update). Change an existing subscription with Change subscription.
quantity is credits drawn from the org pool (GET /api/v1/credits/) for that on-demand product. This call does not charge. Subscription seats are purchased with POST /api/v1/seats/. On-demand credits are purchased with POST /api/v1/credits/.
POST /api/v1/members/70001/product/
{
"product_id": 201,
"quantity": 1,
"idempotency_key": "product-70001-201-a1"
}
| Field | Required | Description |
|---|---|---|
product_id |
Yes | An on-demand product from GET /api/v1/products/ |
quantity |
No | Credits to assign from the org pool. Default 1 |
idempotency_key |
No | Up to 64 characters. Repeating a request with the same key returns the original assignment and does not draw from the pool again |
200 OK)Returns the account object plus member_id and exposures_activated. The product is assigned on that member. When is_primary is true, the account stores it in products, and in primary_subscription_product_id when the product is a subscription. exposures_activated is how many exposures moved from BLOCKED to ACTIVE when the member only had on-demand products and a subscription is added. The count is 0 when the added product is on-demand.
{
"account_id": 12000,
"member_id": 70001,
"status": "ACTIVE",
"primary_subscription_product_id": null,
"products": [
{
"product_id": 201,
"product_name": "Core Scan",
"description": "On-demand scan of the Core catalog.",
"charge_type": "on_demand",
"billing_frequency": "one_time",
"price_per_seat": 299,
"credits_remaining": 1
}
],
"exposures_activated": 0,
"members": [
{
"member_id": 70001,
"primary_name": "Scan User",
"primary_email": "scan@company.com",
"is_primary": true,
"profile_threat_score": null
}
]
}
| Status | Condition |
|---|---|
400 |
product_id missing or unknown; product_id is a subscription (error: use_subscription_update); quantity not a positive integer; idempotency_key reused with a different body (error: idempotency_conflict); account is TERMINATED |
401 |
Missing or invalid API key |
402 |
Org pool available for that product is less than quantity, including when it is 0 (error: insufficient_credits; includes product_id and available). Buy with POST /api/v1/credits/ and retry |
403 |
Partner inactive |
404 |
Member not found |
A subscription is {member_id} plus {product_id}. There is no separate subscription id. primary_subscription_product_id on the account is that product_id when the member is primary, or null when the member has no subscription.
GET /api/v1/members/{member_id}/subscriptions/{product_id}/
GET /api/v1/members/67890/subscriptions/103/
{
"member_id": 67890,
"product_id": 103,
"product_name": "Advanced",
"description": "Scan and removal subscription on the Advanced catalog.",
"charge_type": "subscription",
"billing_frequency": "monthly",
"price_per_seat": 1999,
"credits_remaining": null,
"changes": [
{
"id": 12,
"action": "upgrade",
"from_product_id": 101,
"to_product_id": 103,
"exposures_blocked": 0,
"created_at": "2026-10-05T15:00:00.000000+00:00"
}
]
}
POST /api/v1/members/{member_id}/change_subscription/{new_product_id}/
{new_product_id} is the subscription to move to, from GET /api/v1/products/.
The same seat moves to that product. No seat is added and none is returned. primary_subscription_product_id becomes {new_product_id}. An upgrade from Core to Advanced is charged on the partner payment method at the time of the change (Upgrade Core to Advanced).
Each change is kept on the subscription in changes, newest first. action is upgrade or downgrade when the product type changes, and billing_frequency when only the term changes.
Moving to a product that covers less, such as Advanced to Core, marks exposures BLOCKED when the new product does not cover them. Exposures already IGNORED stay IGNORED. The count is exposures_blocked.
POST /api/v1/members/67890/change_subscription/103/
{
"idempotency_key": "upgrade-67890-103-a1"
}
| Field | Required | Description |
|---|---|---|
idempotency_key |
No | Up to 64 characters. Repeating a request with the same key returns the original change and does not charge again |
200 OK)Returns the subscription, including the new row in changes. exposures_blocked is 0 when the new product still covers the member’s exposures.
| Status | Condition |
|---|---|
400 |
{new_product_id} is not a subscription, is the product already on the member, or the member has no subscription (error: invalid_subscription_change); idempotency_key reused with a different body (error: idempotency_conflict); account is TERMINATED |
401 |
Missing or invalid API key |
402 |
No partner payment method on file (error: payment_method_required), or the charge was declined (error: payment_failed) |
403 |
Partner inactive |
404 |
Member or product not found |
GET /api/v1/members/{member_id}/
Resolve {member_id} from Account members, List members, results[].members[].member_id on GET /api/v1/accounts/, or the account-create response.
| Status | Condition |
|---|---|
401 |
Missing or invalid API key |
403 |
Partner inactive |
404 |
Member not found |
Returned on GET /api/v1/members/{member_id}/, and PATCH /api/v1/members/{member_id}/ (200). Read-only; not accepted on create or update requests.
| Field | Description |
|---|---|
is_primary |
true for the primary member on the account. The member created with the account is true |
profile_threat_score |
Normalized threat score for this profile (numeric). Available only to select beta users. |
PATCH /api/v1/members/{member_id}/
Accepts only scalar birth fields (birth_year, birth_month, birth_day). Sending any other field (e.g. names, addresses) returns 400 Bad Request. Use the nested attribute endpoints to update other profile data.
Success: 200 OK with the updated member body (same shape as retrieve).
| Status | Condition |
|---|---|
400 |
Invalid birth fields, or unrecognised fields sent in the request body |
401 |
Missing or invalid API key |
403 |
Partner inactive |
404 |
Member not found |
DELETE /api/v1/members/{member_id}/
Requires ?confirm=true. Clears all PII immediately (not recoverable), returns a confirmation message, and preserves metadata only. Does not delete sibling profiles on the account. Deleting the whole account clears PII across every profile (Delete an account). You must have at least one primary member associated with an account for it to be active, you will not be able to delete the primary member and instead should delete the account object.
DELETE /api/v1/members/67890/?confirm=true
Send confirm=true only after the end user has explicitly agreed in your UI — never hard-code it.
200 OK){
"message": "Member deleted. All personally identifiable information has been permanently cleared and cannot be recovered.",
"member_id": 67890,
"terminated_at": "2026-05-19T14:30:00Z",
"metadata": {
"account": {
"account_id": 12345,
"created_at": "2026-01-15T10:00:00Z",
"terminated_at": null
},
"member": {
"created_at": "2026-01-20T09:00:00Z",
"terminated_at": "2026-05-19T14:30:00Z"
},
"results_found_count": 42,
"profile_attributes_count": 5
}
}
| Status | Condition |
|---|---|
400 |
confirm query parameter missing or not true |
404 |
Member not found |
Nested routes (under /api/v1/members/{member_id}/):
| Resource | List | Add | Update | Remove |
|---|---|---|---|---|
| Names | GET .../names/ |
POST .../names/ |
(none) | DELETE .../names/{name_id}/ |
| Emails | GET .../emails/ |
POST .../emails/ |
(none) | DELETE .../emails/{email_id}/ |
| Addresses | GET .../addresses/ |
POST .../addresses/ |
(none) | DELETE .../addresses/{address_id}/ |
| Phone numbers | GET .../phone_numbers/ |
POST .../phone_numbers/ |
(none) | DELETE .../phone_numbers/{id}/ |
| Usernames | GET .../usernames/ |
POST .../usernames/ |
(none) | DELETE .../usernames/{id}/ |
| Employers | GET .../employers/ |
POST .../employers/ |
(none) | DELETE .../employers/{id}/ |
| Scans | GET .../scans/ |
(none) | (none) | (none) |
| Exposures | GET .../exposures/ |
(none) | (none) | (none) |
POST bodies use the same field shapes as in Profile fields (e.g. first_name / last_name for names). See Scans for GET .../scans/ and Exposure API for GET .../exposures/.
Success: 201 Created on POST. Attribute DELETE behavior is described below.
DELETE on nested routes (.../names/{id}/, .../addresses/{id}/, emails, phone numbers, usernames, employers) is not a silent field removal. In-progress exposures depend on the PII they were found with.
When an attribute is deleted, any in-progress exposure tied to that profile and the old information is deleted. Kanary does not keep that PII, so those exposures are no longer worked.
Members cannot have an account without the minimum data required. If you try to delete PII that would leave an account in an empty state, you will see an error.
DELETE /api/v1/members/67890/names/111/?confirm=true
Omit confirm or any value other than true returns 400 Bad Request with an explanation of the delete behavior and required acknowledgements.
200 OK){
"message": "Attribute removed. Active exposures tied to the previous information have been deleted. Add updated PII and run a new scan to find and mitigate exposures under the new profile data.",
"attribute_type": "name",
"attribute_id": 111,
"exposures_deleted_count": 3
}
Only 1 attribute can be primary in each category; setting a new attribute to is_primary: true flips the old one to false.
Set is_primary on POST when adding a record, or call the set_primary action on an existing one:
POST /api/v1/members/67890/addresses/
{
"street_address": "123 Main St",
"city": "New York City",
"state": "NY",
"country": "US",
"is_primary": true
}
POST /api/v1/members/67890/names/111/set_primary
POST /api/v1/members/67890/addresses/333/set_primary
POST /api/v1/members/67890/emails/222/set_primary
Setting is_primary: true on one name, email, or address makes that record the profile’s preferred name, primary email, or most recent/active address.
DELETE is rejected with 400 if it would leave the profile without at least one of each required attribute. The email minimum applies only to primary members (accounts require a primary member to have a contact email; secondary profiles may delete their last email).
GET /api/v1/members/{member_id}/scans/
A paginated list of scans: in-progress (PENDING, QUEUED, RUNNING) and completed (SUCCESSFUL, FAILED). See Pagination.
Subscription products scan on a built-in cadence (Product API). Queue an on-demand scan with Scan API.
| Param | Default | Max | Description |
|---|---|---|---|
limit |
100 |
250 |
Scan jobs per page (Pagination) |
offset |
0 |
— | Starting index into the scan history |
GET /api/v1/members/67890/scans/?limit=50&offset=0
200 OK){
"member_id": 67890,
"next_scan_date": "2026-05-22T08:00:00Z",
"count": 3,
"next": null,
"previous": null,
"results": [
{
"id": "job-uuid-3",
"site_id": 12,
"site_domain": "site.domain",
"status": "in_progress",
"created_at": "2026-05-21T09:00:00Z",
"completed_at": null
},
{
"id": "job-uuid-1",
"site_id": 12,
"site_domain": "site.domain",
"status": "success",
"created_at": "2026-05-18T10:00:00Z",
"completed_at": "2026-05-18T10:42:00Z"
},
{
"id": "job-uuid-2",
"site_id": 45,
"site_domain": "site.domain",
"status": "failure",
"created_at": "2026-05-15T14:00:00Z",
"completed_at": "2026-05-15T14:05:00Z"
}
]
}
| Field | Type | Description |
|---|---|---|
member_id |
integer | Profile queried |
next_scan_date |
string (ISO 8601, UTC) | null | perform_at of the earliest PENDING (not yet claimed) job for this member. null if no PENDING jobs exist. Jobs already QUEUED or RUNNING are excluded — they are in flight, not scheduled. |
count |
integer | Total scan jobs for this member |
next |
string | null | URL for the next page, or null |
previous |
string | null | URL for the previous page, or null |
results |
array | Current page of scan jobs (in-progress and completed) |
Each entry in results:
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Scan job id |
site_id |
integer | Site the job ran against (Site API) |
site_domain |
string | Domain of the site (e.g. site.domain) |
status |
string | in_progress, success, or failure |
created_at |
string (ISO 8601, UTC) | When the scan job was created |
completed_at |
string (ISO 8601, UTC) | null | When the job finished; null while status is in_progress |
results is ordered newest created_at first. Follow next until it is null to retrieve full history.
| Status | Condition |
|---|---|
400 |
Invalid limit or offset |
401 |
Missing or invalid API key |
403 |
Partner inactive |
404 |
Member not found |