Node.js client
Install @piedap/consent on your server, create a ConsentClient and look up every client method with its parameters, return value and errors.
ConsentClient calls the API from your Node.js server with a secret key. Each method is listed here with its parameters, what it returns and the errors it throws.
Requirements#
- Node.js 18 or later. The package has no dependencies.
- A secret key,
sk_…. See Keys and where to use them. - Code that runs on your server. The client refuses to start in a browser.
Install#
Your deployment serves the package, so there is no registry to set up.
npm install https://console.dpconsent.com/consent/sdk/v1/packages/piedap-consent-1.0.7.tgzThe package works with import and with require.
import { ConsentClient } from '@piedap/consent/server';
// or
const { ConsentClient } = require('@piedap/consent/server');Create the client#
new ConsentClient()#
Creates a client that keeps no state between calls. Create one when your application starts and share it. Pass one object with these fields:
| Parameter | Type | Required or default | Description |
|---|---|---|---|
apiUrl |
string |
Required | The deployment address. A trailing slash is removed. |
secretKey |
string |
Required | The secret key, sk_…. |
timeoutMs |
number |
10000 |
How long to wait for each attempt. |
maxRetries |
number |
2 |
Further attempts after the first, for calls that are safe to repeat. 0 turns retries off. |
fetch |
typeof fetch |
The global fetch |
A replacement fetch, for tests or a proxy. |
Returns a ConsentClient.
Errors Throws Error in a browser, when secretKey is missing, and when secretKey starts with pk_.
import { ConsentClient } from '@piedap/consent/server';
export const client = new ConsentClient({
apiUrl: 'https://console.dpconsent.com/consent',
secretKey: process.env.PIEDAP_SECRET_KEY,
});Retries and timeouts#
- Only calls that are safe to repeat are retried: every
GET, and any write you gave anidempotencyKey. - A retry follows a network error, a timeout, or a
502,503or504response. - The client waits 400 ms before the second attempt and 800 ms before the third.
- Every other failure, including
429, is thrown at once. checkConsent()waits one second unless you passtimeoutMs, and never retries.- Redirects are never followed, so the key is never sent to another host.
- Each request sends
User-Agent: piedap-consent-node/1.0.7.
Methods#
Every method returns a promise. The scope is what the secret key must hold. Every secret key holds consent:read and consent:write.
| Method | Calls | Scope | Fields and responses |
|---|---|---|---|
resolveForm() |
GET /sdk/v1/form |
None | Forms |
recordConsent() |
POST /sdk/v1/consent |
consent:write |
Consent |
getConsent() |
GET /sdk/v1/consent/{consentId} |
consent:read |
Consent |
updateConsent() |
PATCH /sdk/v1/consent/{consentId} |
consent:write |
Consent |
withdrawConsent() |
POST /sdk/v1/consent/{consentId}/withdraw |
consent:write |
Consent |
receipt() |
GET /sdk/v1/consent/{consentId}/verify |
consent:read |
Receipts |
publicKeys() |
GET /sdk/v1/keys |
None | Receipts |
startEmailConsent() |
POST /sdk/v1/email-consent |
consent:write |
Email consent |
recordActivity() |
POST /sdk/v1/activity |
consent:write |
Activity |
consentPortalLink() |
POST /sdk/v1/pages/consent-portal-links |
consent:write |
Hosted page links |
unsubscribeLink() |
POST /sdk/v1/pages/unsubscribe-links |
None | Hosted page links |
findSubject() |
POST /sdk/v1/subjects/find |
subjects:read |
People |
exportSubject() |
POST /sdk/v1/subjects/export |
subjects:export |
People |
withdrawSubject() |
POST /sdk/v1/subjects/withdraw |
subjects:withdraw |
People |
eraseSubject() |
POST /sdk/v1/subjects/erase |
subjects:erase |
People |
linkIdentifiers() |
POST /sdk/v1/subjects/link |
subjects:link |
People |
linkRequest() |
POST /sdk/v1/requests/link |
requests:write |
Rights requests |
checkConsent() |
POST /sdk/v1/consent/check |
consent:check |
Consent check |
Every method throws ConsentApiError when a call fails. A key without the scope gets the code SCOPE_REQUIRED.
resolveForm()#
Returns the published form for a place or a form key, with its purposes. The params object and each of its fields are optional.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
params.country |
string |
Detected from the request | Two-letter country code. Your server makes the request, so pass the visitor's country. |
params.subdivision |
string |
None | State or province code. |
params.formKey |
string |
Chosen by region rules | Serves one form instead of resolving by region. |
params.locale |
string |
None | The language to ask for. |
params.consentId |
string |
None | An existing consent, whose state is returned alongside. |
Returns Promise<Record<string, any>>, the API's JSON. form is null when no form is served for that place.
Errors Throws ConsentApiError, with the code FORM_NOT_FOUND when no form has that formKey.
const { form } = await client.resolveForm({
formKey: 'kalpa-account',
country: 'IN',
});
const keys = form.purposes.filter((p) => !p.alwaysOn).map((p) => p.key);recordConsent()#
Records one person's answers to a form. Pass one object with these fields:
| Parameter | Type | Required or default | Description |
|---|---|---|---|
formKey |
string |
Required, unless formId is given |
The form's key. |
formId |
string |
Required, unless formKey is given |
The form's id. |
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
COOKIE_ID |
The kind of identifier. Always pass it for an email address, a phone number or your own id. |
decisions |
DecisionInput[] |
Required | One answer for every purpose that is not always on. |
countryCode |
string |
Detected from the request | Where the consent was collected. Without it the server locates your server, not the person. |
subdivisionCode |
string |
None | State or province code. |
locale |
string |
None | The language the form was shown in. |
pageUrl |
string |
None | The page the answer was given on. Up to 500 characters. |
metadata |
Record<string, unknown> |
None | Your own fields, stored with the record. Up to 8,192 bytes of JSON. |
collectedAt |
string |
The time of the call | When the consent was given, as an ISO 8601 time. Not in the future and not before 1990. |
basis |
'PRE_COMMENCEMENT' |
None | With collectedAt: your statement that the person consented before the DPDP Act commenced. |
noticeReference |
string |
None | With collectedAt: the name or number of the notice the person saw. Never personal data. |
noticeSha256 |
string |
None | With collectedAt: the SHA-256 of that notice, 64 hex characters. |
expiresAt |
string |
The form's term from the call | With collectedAt: when this consent ends. |
lastActivityAt |
string |
The time of the call | With collectedAt: when the person last used your service. |
capture |
CaptureInput |
None | How consent was taken in a branch, by phone or on paper. Sealed with this write only. |
interaction |
string |
API, or IMPORT with collectedAt |
The control that produced the answer, such as SAVE_PREFERENCES. |
identifiers |
LinkedIdentifier[] |
None | Up to five other identifiers to find the record by. |
dataPrincipalType |
string |
None | CHILD or PERSON_WITH_DISABILITY, when a parent or lawful guardian consents for the person. |
guardian |
Guardian |
None | How the parent or guardian was verified. |
ageDeclaration |
{ eighteenOrOver: boolean } |
None | The person's own answer to "Are you 18 or older?". false records the person as a child, and a grant then needs guardian. |
idempotencyKey |
string |
None | A unique key for this write. It makes the call safe to retry. |
Returns Promise<ConsentRecord>, the record as stored.
Errors Throws ConsentApiError. The code is INVALID_PURPOSE_DECISIONS when a purpose is not answered or not on the form. It is INVALID_PARTNER_DECISIONS for a partner answer the form version does not offer. A write with collectedAt that the records overrule throws with status 409 and one of five codes; isSkippedImport() tells them apart. See Bring in consent given earlier.
const record = await client.recordConsent({
formKey: 'kalpa-account',
subjectId: 'usr_8f2K1xQz',
subjectType: 'USER_ID',
countryCode: 'IN',
interaction: 'SAVE_PREFERENCES',
decisions: [
{ purposeKey: 'course-updates-by-email', granted: true },
{ purposeKey: 'class-reminders-by-sms', granted: false },
],
identifiers: [
{ subjectType: 'EMAIL', subjectId: 'kavya.menon@example.in' },
],
idempotencyKey: 'signup-usr_8f2K1xQz',
});See Record consent.
getConsent()#
Returns one record by its id.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
consentId |
string |
Required | The record's id, cns_…. |
Returns Promise<ConsentRecord | null>. The object also carries metadata, your own fields, and captureContext when the current state has one.
Errors Throws ConsentApiError, except for a 404. The method then returns null: the id is not known, or the record's expiresOn has passed.
const record = await client.getConsent('cns_3f1c9a7e2b8d4c6fa0e5d1b7c9f2a4e8');
const mayEmail = record?.decisions.some(
(d) => d.purposeKey === 'course-updates-by-email' && d.granted,
);Check status as well. A PENDING record has not been confirmed yet.
updateConsent()#
Changes answers on an existing record.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
consentId |
string |
Required | The record to change. |
decisions |
DecisionInput[] |
Required | Only the purposes that changed. The others keep their answer. |
options.pageUrl |
string |
None | The page the change was made on. |
options.interaction |
string |
None | The control that produced the change, such as PREFERENCE_CENTRE. |
options.identifiers |
LinkedIdentifier[] |
None | More identifiers to link to the record. |
options.capture |
CaptureInput |
None | How this change was taken. A change without it clears the record's capture context; the earlier event keeps it. |
Returns Promise<ConsentRecord>, the record as updated.
Errors Throws ConsentApiError. The code is CONSENT_RECORD_NOT_FOUND for an unknown id and CONSENT_RECORD_ERASED for an erased record. The call takes no idempotency key, so it is never retried.
await client.updateConsent(
'cns_3f1c9a7e2b8d4c6fa0e5d1b7c9f2a4e8',
[{ purposeKey: 'class-reminders-by-sms', granted: true }],
{ interaction: 'PREFERENCE_CENTRE' },
);withdrawConsent()#
Withdraws one record.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
consentId |
string |
Required | The record to withdraw. |
reason |
string |
None | Stored on the withdrawal. |
Returns Promise<ConsentRecord>. Withdrawing twice is safe: the second call returns the record unchanged.
Errors Throws ConsentApiError, with the code CONSENT_RECORD_NOT_FOUND for an unknown id.
await client.withdrawConsent(
'cns_3f1c9a7e2b8d4c6fa0e5d1b7c9f2a4e8',
'Asked by phone',
);receipt()#
Returns the signed receipt for one record.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
consentId |
string |
Required | The record's id. |
Returns Promise<SignedReceipt>. The server's verdict on the proof chain is at receipt.proof.verification.valid.
Errors Throws ConsentApiError, with the code CONSENT_RECORD_NOT_FOUND for an unknown id.
const signed = await client.receipt('cns_3f1c9a7e2b8d4c6fa0e5d1b7c9f2a4e8');
console.log(signed.receipt.proof.verification.valid);publicKeys()#
Returns the deployment's public signing keys, for verifyReceipt().
Takes no parameters.
Returns Promise<{ keys: Jwk[] }>. The keys change rarely, so fetch them once and keep them.
Errors Throws ConsentApiError.
const keys = await client.publicKeys();startEmailConsent()#
Starts a double opt-in for an email address. Pass one object with these fields:
| Parameter | Type | Required or default | Description |
|---|---|---|---|
email |
string |
Required | The address to confirm. |
formKey |
string |
Required | The form the person answered. |
decisions |
DecisionInput[] |
Required | The answers to hold until the person confirms. |
locale |
string |
None | The language the form was shown in. |
returnUrl |
string |
None | Where the person lands after confirming. |
idempotencyKey |
string |
None | A unique key for this write. |
Returns Promise<{ consentId, status, verificationUrl, expiresOn }>. status is PENDING. Send verificationUrl to the address in your own email.
Errors Throws ConsentApiError.
const pending = await client.startEmailConsent({
email: 'rohan.das@example.in',
formKey: 'newsletter',
decisions: [{ purposeKey: 'newsletter', granted: true }],
returnUrl: 'https://www.example.in/subscribed',
});See Email double opt-in.
recordActivity()#
Reports that a person signed in or used your service.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
None | The kind of identifier. Pass it whenever the identifier is not an email address. |
Returns Promise<{ records: number, activeOn: string }>.
Errors Throws ConsentApiError.
await client.recordActivity('usr_8f2K1xQz', 'USER_ID');consentPortalLink()#
Creates a seven-day link to the hosted page where the person reviews, withdraws and downloads their consents.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
None | The kind of identifier. Pass it whenever the identifier is not an email address. |
Returns Promise<{ url: string, expiresOn: string }>.
Errors Throws ConsentApiError.
const { url } = await client.consentPortalLink('usr_8f2K1xQz', 'USER_ID');See Hosted pages.
unsubscribeLink()#
Creates a one-click unsubscribe link for a record, with the headers to add to your messages.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
consentId |
string |
Required | The record's id. |
Returns Promise<{ consentId, url, headers }>. headers holds List-Unsubscribe and List-Unsubscribe-Post.
Errors Throws ConsentApiError.
const { url, headers } = await client.unsubscribeLink(record.consentId);findSubject()#
Returns every record held for a person.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
Required | The kind of identifier. |
Returns Promise<SubjectRecords>. Each record's matchedBy lists the identifier types that matched.
Errors Throws ConsentApiError.
const { records } = await client.findSubject('+919846011223', 'PHONE');See Find a person.
exportSubject()#
Returns signed receipts and a processing summary for an access request.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
Required | The kind of identifier. |
Returns Promise<AccessPackage>.
Errors Throws ConsentApiError.
const pack = await client.exportSubject('usr_8f2K1xQz', 'USER_ID');withdrawSubject()#
Withdraws every consent the person has given, linked records included.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
Required | The kind of identifier. |
options.reason |
string |
None | Stored on the withdrawal. |
options.idempotencyKey |
string |
None | A unique key for this write. |
Returns Promise<{ withdrawn: number }>, the number of records withdrawn.
Errors Throws ConsentApiError.
await client.withdrawSubject('usr_8f2K1xQz', 'USER_ID', {
reason: 'Account closed',
idempotencyKey: 'close-usr_8f2K1xQz',
});eraseSubject()#
Erases the person's identifiers on every record, linked records included, and removes the links.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
Required | The kind of identifier. |
options.reason |
string |
None | Stored with the erasure. |
options.idempotencyKey |
string |
None | A unique key for this write. |
Returns Promise<{ erased: number }>, the number of records erased.
Errors Throws ConsentApiError.
await client.eraseSubject('usr_8f2K1xQz', 'USER_ID', {
reason: 'Erasure requested by the learner',
idempotencyKey: 'erase-usr_8f2K1xQz',
});See Withdraw and erase.
linkIdentifiers()#
Links more identifiers to every live record of a person.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The identifier the records are kept under. |
subjectType |
SubjectType |
Required | The kind of identifier. |
identifiers |
LinkedIdentifier[] |
Required | Up to five identifiers. Any proof is left out: a secret key needs none. |
options.idempotencyKey |
string |
None | A unique key for this write. |
Returns Promise<{ records: number, added: number }>: records linked, and links added.
Errors Throws ConsentApiError.
await client.linkIdentifiers('usr_8f2K1xQz', 'USER_ID', [
{ subjectType: 'PHONE', subjectId: '+919846099887' },
]);See Identifiers.
linkRequest()#
Points a rights request at the identifier the person's records are kept under.
| Parameter | Type | Required or default | Description |
|---|---|---|---|
reference |
string |
Required | The request's reference, such as DSR-2026-4F2A91. |
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
Required | The kind of identifier. |
Returns Promise<{ reference: string, linkedRecordCount: number }>.
Errors Throws ConsentApiError, with the code REQUEST_NOT_FOUND when no request in the project has that reference.
await client.linkRequest('DSR-2026-4F2A91', 'usr_8f2K1xQz', 'USER_ID');checkConsent()#
Asks whether one person allows a purpose now. Pass one object with these fields, and an optional second object:
| Parameter | Type | Required or default | Description |
|---|---|---|---|
subjectId |
string |
Required | The person's identifier. |
subjectType |
SubjectType |
Required | The kind of identifier. |
purposeKey |
string |
Required | The purpose to check. |
partnerKey |
string |
None | One partner the purpose lists. |
alsoKnownAs |
{ subjectType, subjectId }[] |
None | Up to four other identifiers of the same person. |
options.timeoutMs |
number |
1000 |
How long to wait for the answer. |
Returns Promise<ConsentCheckResult>. allowed is true only when the server answered granted. When no answer arrived, the result is unavailable and never allowed:
| What happened | cause |
status |
|---|---|---|
No answer within timeoutMs |
timeout |
null |
| A network error | network |
null |
429, the check or key budget |
rate_limited |
429, with retryAfter |
500 or above |
server_error |
The status |
Errors Throws ConsentApiError for 400, 401, 403 and 404, such as SCOPE_REQUIRED or PURPOSE_NOT_FOUND. Throws Error before any request when alsoKnownAs has more than four entries.
const check = await client.checkConsent({
subjectId: 'CIF00412873',
subjectType: 'USER_ID',
alsoKnownAs: [{ subjectType: 'PHONE', subjectId: '+919846011223' }],
purposeKey: 'offers-by-sms',
});
if (check.allowed) await sendOffer(check.basedOn[0].consentId);See Check consent before you process.
Types#
type SubjectType =
| 'COOKIE_ID' | 'SESSION_ID' | 'EMAIL'
| 'USER_ID' | 'PHONE' | 'DEVICE_ID';
interface DecisionInput {
purposeKey: string;
granted: boolean;
partners?: Record<string, boolean>; // a partner left out follows granted
}
interface LinkedIdentifier {
subjectType: SubjectType;
subjectId: string;
proof?: string; // only for writes from a browser
}
interface Guardian {
relationship: 'PARENT' | 'LAWFUL_GUARDIAN';
verification:
| 'EXISTING_ACCOUNT' | 'VIRTUAL_TOKEN'
| 'IDENTITY_DOCUMENT' | 'GUARDIANSHIP_ORDER';
reference: string;
}
interface ConsentRecord {
consentId: string;
status:
| 'GRANTED' | 'PARTIAL' | 'DENIED'
| 'WITHDRAWN' | 'EXPIRED' | 'PENDING';
formId: string;
formVersion: number;
formContentHash: string;
decisions: {
purposeKey: string;
purposeName: string;
category: string;
granted: boolean;
partners?: Record<string, boolean>;
}[];
test: boolean; // written with a test key
countryCode: string | null;
subdivisionCode: string | null;
lawCode: string | null;
collectedOn: string;
expiresOn: string | null;
withdrawnOn: string | null;
proofHash: string;
}
interface SubjectRecords {
subjectType: SubjectType;
fingerprint: string;
records: (ConsentRecord & {
erasedOn: string | null;
matchedBy: SubjectType[];
})[];
}
interface AccessPackage {
subjectType: SubjectType;
generatedOn: string;
summary: Record<string, any>;
contact: Record<string, any>;
receipts: SignedReceipt[];
}
interface SignedReceipt {
receipt: Record<string, any>;
signature: string;
algorithm: 'Ed25519' | 'HMAC-SHA256';
keyId?: string;
publicKeysUrl?: string;
}
interface Jwk { kid: string; kty: string; crv: string; x: string }
type CaptureChannel =
| 'BRANCH' | 'CALL_CENTRE' | 'IVR'
| 'KIOSK' | 'FIELD_AGENT' | 'PAPER';
type CaptureMethod =
| 'OTP' | 'SIGNED_DECLARATION' | 'RECORDED_CALL'
| 'PAPER_FORM' | 'LINK_CONFIRMED' | 'AGENT_ATTESTED';
interface CaptureInput {
channel: CaptureChannel;
method: CaptureMethod;
agentRef?: string; // stored only as a keyed hash
locationRef?: string; // stored only as a keyed hash
methodReference?: string; // never personal data
documentSha256?: string;
}
interface CaptureContext {
channel: CaptureChannel;
method: CaptureMethod;
agentRefHash?: string;
locationRefHash?: string;
methodReference?: string;
documentSha256?: string;
attestedByStaffOnly: boolean;
}
// ConsentRecord also has, when set:
// basis?: 'PRE_COMMENCEMENT';
// noticeReference?: string;
// noticeSha256?: string;
// captureContext?: CaptureContext; // secret-key responses only
type ConsentCheckReason =
| 'granted' | 'refused' | 'withdrawn' | 'expired'
| 'pending_confirmation' | 'erased' | 'no_record'
| 'not_consent_based';
interface ConsentCheckBasis {
consentId: string | null;
proofHash: string | null;
state:
| 'granted' | 'refused' | 'withdrawn'
| 'expired' | 'erased' | 'pending_confirmation';
decidedAt: string | null;
matchedBy: SubjectType[];
attestedByStaffOnly: boolean;
}
interface ConsentCheck {
unavailable: false;
allowed: boolean;
reason: ConsentCheckReason;
recorded: Exclude<ConsentCheckReason, 'not_consent_based'>;
purpose: {
key: string;
category: string;
declaredBasis: string;
legitimateUse: string | null;
consentRequired: boolean;
};
partnerKey: string | null;
validUntil: string | null;
basedOn: ConsentCheckBasis[];
notes: (
| 'PARTNER_NOT_OFFERED' | 'PHONE_NOT_E164'
| 'ATTESTED_BY_STAFF_ONLY' | 'WITHDRAWAL_IN_PROGRESS'
)[];
checkedAt: string;
test: boolean;
}
interface ConsentCheckUnavailable {
unavailable: true;
allowed: false;
reason: 'unavailable';
cause: 'timeout' | 'network' | 'rate_limited' | 'server_error';
status: number | null;
retryAfter: number | null;
}
type ConsentCheckResult = ConsentCheck | ConsentCheckUnavailable;Guardian is not an exported name. It is shown here for the shape of guardian.
Errors#
A failed call throws ConsentApiError.
| Field | Type | Description |
|---|---|---|
name |
string |
Always ConsentApiError. |
message |
string |
The server's message. Several validation messages are joined with ; . |
status |
number |
The HTTP status. 0 when no response arrived: message is then Request timed out or Network error. |
code |
string | null |
The server's code, such as SCOPE_REQUIRED. Not every error has one. With no response, TIMEOUT or NETWORK_ERROR. |
retryAfter |
number | null |
Seconds from the Retry-After header, set on a 429. |
import { ConsentApiError } from '@piedap/consent/server';
try {
await client.eraseSubject(user.id, 'USER_ID');
} catch (error) {
if (!(error instanceof ConsentApiError)) throw error;
if (error.status === 429) {
await retryLater(error.retryAfter ?? 60);
} else if (error.code === 'SCOPE_REQUIRED') {
// grant subjects:erase to the key in the console
} else {
throw error;
}
}Every code is listed in Errors.