Skip to content

Removing a member

Erase a customer who has left, from your console or from your server — what the signed request carries, what each ID's result means, and what is erased and what is kept.

When a customer leaves you, you can remove them from Doshi in two ways: one at a time from your console, or in batches from your server. Both do the same thing. The member and everything of theirs is erased at once, in Live or Test. There is no grace period, and no undo.

On the Members page, search for the member by customer ID, or find them in the list. Members who sign in to Doshi directly, through the hosted link, are there too. Press Remove member’s data, read what it erases, and type the customer ID to confirm. A member who signs in directly has no customer ID, so you type the member id the dialog shows instead.

Only an Admin can remove a member. It works in Live and in Test, on the environment your console’s header shows. Doshi records who removed the member, and when.

Your server posts a list of customer IDs, the sub you sign each member in with, and Doshi erases their members. The request is signed with a signing key you already have: there is no new credential to set up.

POST /members/erase HTTP/1.1
Host: gateway.doshi.app
Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6...
Content-Type: application/json
{"external_ids":["bank-user-0042","bank-user-0043"]}

The host is the token exchange’s, from the exchange URL in your console’s Setup values. Live and Test share it: the key that signed the request decides which environment’s members are erased.

Part Value
Authorization Bearer and the erase assertion below.
Content-Type application/json
Body A JSON object whose external_ids holds 1 to 500 customer IDs, each a non-empty string of at most 255 characters.

The erase assertion is a JWT, signed exactly as a sign-in assertion is: the same key, the same kid and alg in its header, and the same iss. Its claims differ.

Claim Required What it must be
iss yes Your issuer, from Setup values, as on every assertion.
aud yes doshi:members:erase, as a string, and nothing else.
iat yes Issued-at.
exp yes Expiry, at most 300 s after iat.
jti yes A fresh unique id for every call.
body_sha256 yes The SHA-256 of the exact body bytes you send, base64url-encoded without padding.
nbf no Not-before, if you send one.

There is no sub: the customer IDs are in the body, and the hash ties the assertion to that body. The clock skew we tolerate is the same ±60 s, and the same privileged claims are refused.

The two kinds of assertion cannot stand in for each other. The token exchange refuses an erase assertion, and the erase refuses a sign-in assertion, both with aud_mismatch.

Sign a new assertion for every call, a retry included. We remember each jti until its assertion expires, and refuse it a second time with erase_jti_replayed.

The erase checks your environment’s Allowed IPs exactly as the token exchange does. Live accepts only addresses on its list, and Test accepts any address while its list is empty.

Its rate limits are the token exchange’s defaults, counted separately from your exchanges. Over them, you get HTTP 429 with a Retry-After header.

This is the whole client side, with jose. It reads the same configuration as Sign an assertion and imports its ClientConfig and ExchangeError from that file. Like that snippet, this is the file this page imports, typechecked and unit-tested on every build.

erase-members.ts
// Erase members by their External IDs: sign an erase assertion over the request body and post
// it. Backend only, as the sign-in snippet is, and with the same config and Signing key.
import { base64url, importPKCS8, SignJWT } from "jose";
import { type ClientConfig, ExchangeError } from "./sign-assertion";
/** The erase's path, on the token exchange's host. */
export const ERASE_PATH = "/members/erase";
/** An erase assertion's `aud`, and nothing else in it. */
export const ERASE_AUDIENCE = "doshi:members:erase";
/** Assertion lifetime: 60 s, inside the same 300 s ceiling the token exchange enforces. */
export const ERASE_ASSERTION_TTL_SECONDS = 60;
/** What became of one External ID, in the order you asked. */
export type EraseResult = {
external_id: string;
result: "erased" | "already_erased" | "not_found";
};
/** The erase's URL: the token exchange URL from Setup values, with the erase's path. */
export function eraseUrl(config: ClientConfig): string {
return new URL(ERASE_PATH, config.audience).href;
}
/** SHA-256 of the exact body, base64url without padding: the `body_sha256` claim. */
export async function bodySha256(body: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
return base64url.encode(new Uint8Array(digest));
}
/**
* Sign an erase assertion for one request body. It has no `sub`: the IDs are in the body,
* and `body_sha256` ties the assertion to it. Sign a new one for every call, a retry
* included, because the erase refuses a `jti` it has seen.
*/
export async function signEraseAssertion(config: ClientConfig, body: string): Promise<string> {
const key = await importPKCS8(config.privateKeyPkcs8, config.algorithm);
const now = Math.floor(Date.now() / 1000);
return new SignJWT({ body_sha256: await bodySha256(body) })
.setProtectedHeader({ alg: config.algorithm, kid: config.keyId, typ: "JWT" })
.setIssuer(config.issuer)
.setAudience(ERASE_AUDIENCE)
.setIssuedAt(now)
.setExpirationTime(now + ERASE_ASSERTION_TTL_SECONDS)
.setJti(crypto.randomUUID())
.sign(key);
}
/**
* Erase up to 500 members by their External IDs, the `sub` you sign them in with. Returns one
* result per ID. A rejection throws `ExchangeError`, and a rejected call erased nobody.
*/
export async function eraseMembers(
config: ClientConfig,
externalIds: string[],
options: { fetchImpl?: (url: string, init: RequestInit) => Promise<Response> } = {},
): Promise<EraseResult[]> {
const doFetch = options.fetchImpl ?? fetch;
// The hash is over these exact bytes, so the string signed is the string sent.
const body = JSON.stringify({ external_ids: externalIds });
const assertion = await signEraseAssertion(config, body);
const response = await doFetch(eraseUrl(config), {
method: "POST",
headers: { authorization: `Bearer ${assertion}`, "content-type": "application/json" },
body,
});
// Not JSON when something other than Doshi shaped the response; fetchMemberToken says why.
const answer = (await response.json().catch(() => ({}))) as {
results?: EraseResult[];
error?: string;
error_description?: string;
};
if (!response.ok || answer.results === undefined) {
throw new ExchangeError(
answer.error ?? "",
answer.error_description ?? response.statusText,
response.status,
response.headers.get("retry-after") ?? undefined,
);
}
return answer.results;
}
import { eraseMembers } from "./erase-members";
import { configFromEnv } from "./sign-assertion";
const config = configFromEnv(process.env);
const results = await eraseMembers(config, ["bank-user-0042", "bank-user-0043"]);
// -> [{ external_id: "bank-user-0042", result: "erased" }, ...]

A call that succeeds answers 200 with one result per ID, in the order you asked:

{
"results": [
{ "external_id": "bank-user-0042", "result": "erased" },
{ "external_id": "bank-user-0043", "result": "already_erased" },
{ "external_id": "bank-user-0044", "result": "not_found" }
]
}
result What it means
erased The member was there, and is erased now.
already_erased An earlier erase removed them, and nobody has signed in with that ID since.
not_found This environment has never had a member with that ID.

An ID you list twice answers erased, then already_erased. Repeating a whole call is safe, so after a timeout, send it again with a new assertion.

The erase reaches only the members of the key’s environment. A Test key never erases a Live member, and no key reaches another client’s members.

A refusal is a JSON body shaped like the token exchange’s, with an error code and an error_description. A refused or failed call erases nobody. Every refusal the token exchange also makes comes back in the exchange’s words: see Errors. These are the erase’s own:

ReasonHTTPerrorerror_description startsFix
erase_assertion_missing400invalid_requestthe erase assertion is required, in a bearer Authorization headerSend Authorization: Bearer <assertion>, the erase assertion you signed for this body.
erase_request_malformed400invalid_requestthe body must be a JSON object whose external_ids holds 1 to 500 External IDsSend {"external_ids": [...]} as application/json, each ID a non-empty string, at most 500 in one call.
erase_aud_mismatch400invalid_grantaud must be doshi:members:erase and nothing elseSet aud to "doshi:members:erase". A sign-in assertion, whose aud is the token exchange URL, never erases.
erase_body_hash_missing400invalid_grantthe assertion has no body_sha256 claimPut the base64url SHA-256 of the exact body you send in the body_sha256 claim.
erase_body_hash_mismatch400invalid_grantbody_sha256 is not the hash of the body sentHash the bytes you send, after serialising, and send those same bytes.
erase_jti_replayed400invalid_grantthis assertion's jti was already usedSign a new assertion with a fresh jti for every call, a retry included.
erase_failed500server_errorthe erase failed on our side and erased nothingRetry with a new assertion. Every ID's result is safe to ask for again.

Both ways erase the same things, at once:

  • the member, and everything of theirs: coach conversations and their finance profile, journeys, Journey builder previews and picks, completions, points, redemptions and their fulfilments, signpost views and taps, answers to lesson checks, Money Confidence answers, Onboarding answers and progress, and their goal;
  • the link between your customer ID and the member;
  • the login of a member who signs in directly;
  • the files stored for the member;
  • their analytics profile, if they have one.

A redemption that is held for review or being ordered does not stop the erase. It is cancelled, and the console’s confirmation names its reference. A gift card that is already being ordered may still be issued.

Nothing kept names the member:

  • The token exchange’s records. Each exchange and rejection stays in our audit log, without the sub or the member it named.
  • A one-way mark of the customer ID, within its environment. It is how a repeat answers already_erased. The mark does not contain the ID, but someone who already knows or can guess an ID could check it against the mark.
  • Your billed member count. The member still counts as active in each contract year they were active in, anonymously, so an erase never lowers what that year is billed on.
  • The record of the erase. It names who removed the member, or the kid that signed the request, the environment, and any redemptions it cancelled. It is completed once the stored files and the analytics profile are gone, usually within minutes.

Insights counts only the activity that still exists, so an erased member’s activity leaves past date ranges too.

An erased customer ID can sign in again. They become a new member with no history, and a later erase answers erased for them again.