Skip to content

Not legal advice

dpdpkit is software that helps you implement obligations under India's Digital Personal Data Protection Act, 2023 and the DPDP Rules, 2025. It does not provide legal advice and does not guarantee compliance.

FastAPI adapter

app

DPDPKit โ€” the object a FastAPI developer creates.

DPDPKit

DPDPKit(*, principal_resolver: PrincipalResolver, admin_guard: AdminGuard | None = None, db_url: str | None = None, engine: Any = None, repository: Repository | None = None, config: str | Path | Mapping[str, Any] | None = None, policy: str | Policy | None = None, overlays: Iterable[str] = (), registry: Registry | None = None, notifier: Notifier | None = None, contact: Mapping[str, str] | None = None, contact_resolver: ContactResolver | None = None, identity_verifier: IdentityVerifier | None = None, signing_key: bytes | str | None = None, tenant_id: str = 'default', webhooks: Iterable[WebhookEndpoint] | None = None, migrate_on_startup: bool = False, create_tables: bool = False, **kit_kwargs: Any)

Wire dpdpkit into a FastAPI app.

webhooks are delivered through a database outbox (see dpdpkit.fastapi.webhooks); when omitted they are read from the webhooks: list in the config, with secrets from secret_env.

principal_resolver(request) returns a stable, non-personal reference for the signed-in user (for example the user's primary key), or None. admin_guard(request) returns a role (viewer/handler/admin), True for admin, or None/False for no access.

repository instance-attribute

repository = repository

config instance-attribute

config: dict[str, Any] | None = None

kit instance-attribute

kit = kit_from_config(self.config, repository, registry=registry, contact=contact, **common)

principal_resolver instance-attribute

principal_resolver = principal_resolver

admin_guard instance-attribute

admin_guard = admin_guard

router instance-attribute

router = build_router(self)

webhooks instance-attribute

webhooks: WebhookOutbox | None = None

resolve_principal async

resolve_principal(request: Request) -> str | None

require_principal async

require_principal(request: Request) -> str

resolve_role async

resolve_role(request: Request) -> Role | None

require_role async

require_role(request: Request, needed: Role) -> str

Check the caller's admin role; returns an actor label for the audit trail.

request_metadata staticmethod

request_metadata(request: Request) -> dict[str, Any]

Evidence stored with consent events. No IP address or personal data by default.

run async

run(fn: Callable[P, T], *args: args, **kwargs: kwargs) -> T

Call a kit method from async def code without blocking the event loop::

allowed = await dpdp.run(dpdp.kit.consent.check, user_id, "marketing")

bind

bind(session: Session) -> AbstractContextManager[Session]

Run kit calls inside the app's sync Session; see SqlAlchemyRepository.bind.

run_in_session async

run_in_session(session: AsyncSession, fn: Callable[P, T], *args: args, **kwargs: kwargs) -> T

Call a kit method inside the app's AsyncSession, so its writes commit or roll back with the app's own work::

async with AsyncSession(engine) as s, s.begin():
    s.add(Order(...))
    await dpdp.run_in_session(s, dpdp.kit.consent.grant, user_id, "marketing")

dpdpkit never commits the session. The call runs through AsyncSession.run_sync on the event loop, so keep it to database work; event listeners see the event before the app commits.

require_consent(purpose: str) -> Callable[[Request], Awaitable[None]]

install

install(app: FastAPI, *, prefix: str = '/dpdp', admin_path: str | None = None, track_activity: bool = True) -> None

Include the router, error handlers, activity middleware and the admin dashboard.

install_error_handlers

install_error_handlers(app: FastAPI) -> None

mount_admin

mount_admin(app: FastAPI, path: str = '/dpdp/admin/ui') -> None

Serve the prebuilt admin dashboard bundle. Until it ships (v0.4) a placeholder page is served.

start_scheduler

start_scheduler(*, retention_minutes: int = 15, verify_hour: int = 2, root_export_hour: int | None = 3, root_sinks: Iterable[RootSink] = (), webhook_seconds: int = 30) -> Any

Run retention every retention_minutes, ledger verification daily at verify_hour and a root-hash export daily at root_export_hour UTC (None disables it) (APScheduler).

The export writes to each of root_sinks and publishes ledger.root_exported; subscribe a webhook to that event to hand a copy to a third party. Due webhook deliveries are sent every webhook_seconds when any endpoint is configured.

verify_ledger_job

verify_ledger_job() -> bool

export_root_job

export_root_job(*sinks: RootSink) -> RootHash | None

Export the ledger root hash. A failing sink is logged and the job reports None.

migrate

migrate(revision: str = 'head') -> None

ActivityMiddleware

ActivityMiddleware(app: Any, dpdpkit: DPDPKit, min_interval_seconds: float = 300)

Bases: BaseHTTPMiddleware

Records principal activity (which restarts inactivity clocks and cancels warned erasures). Throttled per principal so busy users do not write on every request.

dpdpkit instance-attribute

dpdpkit = dpdpkit

min_interval instance-attribute

min_interval = min_interval_seconds

dispatch async

dispatch(request: Request, call_next: RequestResponseEndpoint) -> Response
require_consent(purpose: str) -> Callable[[Request], Awaitable[None]]

FastAPI dependency: 403 consent_required unless the caller has consented to purpose.

default_kit

default_kit(request: Request | None = None) -> DPDPKit

repository

SqlAlchemyRepository: dpdpkit-core's Repository on SQLAlchemy 2.0 (Postgres, MySQL, SQLite).

SqlAlchemyRepository

SqlAlchemyRepository(engine: Engine | str | Any)

engine is a URL, an Engine, or the app's AsyncEngine (dpdpkit then opens its own small sync pool to the same database).

engine instance-attribute

engine = engine

Session instance-attribute

Session = sessionmaker(self.engine, expire_on_commit=False)

create_all

create_all() -> None

Create tables directly (tests and demos). Production installs use dpdpkit migrate.

transaction

transaction() -> Iterator[None]

bind

bind(session: Session) -> Iterator[Session]

Run kit calls inside the app's own Session, so they commit or roll back with its work::

with Session(engine) as s, s.begin(), dpdp.repository.bind(s):
    s.add(Order(...))
    dpdp.kit.consent.grant(user_id, "marketing")

dpdpkit never commits a bound session; each write runs in a savepoint. For an AsyncSession use await dpdp.run_in_session(session, fn, ...).

append_consent_event(e: ConsentEvent) -> None
last_consent_event(tenant_id: str) -> ConsentEvent | None
latest_consent_event(tenant_id: str, principal: str, purpose: str) -> ConsentEvent | None
list_consent_events(tenant_id: str, *, principal: str | None = None, after_seq: int = 0, limit: int | None = None) -> list[ConsentEvent]

append_audit

append_audit(a: AuditEntry) -> None

last_audit

last_audit(tenant_id: str) -> AuditEntry | None

list_audit

list_audit(tenant_id: str, *, subject_id: str | None = None, after_seq: int = 0, limit: int | None = None) -> list[AuditEntry]

save_notice

save_notice(notice: Notice) -> None

list_notices

list_notices(tenant_id: str, *, version: int | None = None) -> list[Notice]

save_request

save_request(req: RightsRequest) -> None

get_request

get_request(tenant_id: str, request_id: str) -> RightsRequest | None

list_requests

list_requests(tenant_id: str, *, principal: str | None = None, statuses: Collection[RequestStatus] | None = None) -> list[RightsRequest]

save_nominee

save_nominee(nominee: Nominee) -> None

list_nominees

list_nominees(tenant_id: str, principal: str) -> list[Nominee]

save_schedule

save_schedule(sch: ErasureSchedule) -> None

get_schedule

get_schedule(tenant_id: str, schedule_id: str) -> ErasureSchedule | None

list_schedules

list_schedules(tenant_id: str, *, principal: str | None = None, statuses: Collection[ScheduleStatus] | None = None) -> list[ErasureSchedule]

due_erasures

due_erasures(tenant_id: str, now: datetime) -> list[ErasureSchedule]

save_hold

save_hold(hold: LegalHold) -> None

list_holds

list_holds(tenant_id: str, *, principal: str | None = None, active_only: bool = True) -> list[LegalHold]

record_activity

record_activity(tenant_id: str, principal: str, at: datetime) -> None

last_activity

last_activity(tenant_id: str, principal: str) -> datetime | None

save_delivery

save_delivery(receipt: DeliveryReceipt) -> None

get_delivery

get_delivery(tenant_id: str, receipt_id: str) -> DeliveryReceipt | None

make_engine

make_engine(url: str | URL, **kwargs: Any) -> Engine

Create an engine with settings dpdpkit relies on (SQLite savepoints, shared in-memory DB).

Async URLs (postgresql+asyncpg://, sqlite+aiosqlite:// ...) are accepted and connect with the matching sync driver, which must be installed (dpdpkit-fastapi[postgres] or [mysql]).

webhooks

Durable outbound webhooks: a database outbox with retries, backoff and a dead-letter state.

Publishing an event only inserts one row per matching endpoint, so request handlers never wait on the network. WebhookOutbox.deliver_due (run by the scheduler) sends due rows, reschedules failures with exponential backoff, and marks a row dead after max_attempts. Dead rows stay until someone redelivers them; nothing is dropped silently.

Receivers verify X-DPDPKit-Signature with dpdpkit.events.verify_signature and should treat the event id as an idempotency key: a delivery can arrive more than once.

WebhookOutbox

WebhookOutbox(repository: SqlAlchemyRepository, endpoints: Iterable[WebhookEndpoint], *, clock: Callable[[], datetime], sender: Sender = urllib_sender, max_attempts: int = 10, base_delay: timedelta = timedelta(seconds=30), max_delay: timedelta = timedelta(hours=2), lease: timedelta = timedelta(minutes=5), batch_size: int = 100)

max_attempts sends spaced base_delay * 2**n apart (capped at max_delay): with the defaults, 10 attempts over roughly 4 hours. lease stops two workers sending the same row.

repository instance-attribute

repository = repository

endpoints instance-attribute

endpoints = {e.id: e for e in endpoints}

clock instance-attribute

clock = clock

sender instance-attribute

sender = sender

max_attempts instance-attribute

max_attempts = max_attempts

base_delay instance-attribute

base_delay = base_delay

max_delay instance-attribute

max_delay = max_delay

lease instance-attribute

lease = lease

batch_size instance-attribute

batch_size = batch_size

attach

attach(bus: EventBus) -> None

enqueue

enqueue(event: Event) -> int

Store one pending delivery per endpoint that wants this event. Returns how many.

backoff

backoff(attempts: int) -> timedelta

Delay before the next try, after attempts failed sends.

deliver_due

deliver_due(now: datetime | None = None) -> DeliveryRun

Send every due delivery once. Safe to run from several processes at the same time.

deliveries

deliveries(status: str | None = None, *, limit: int = 100) -> list[WebhookDelivery]

dead_letters

dead_letters(*, limit: int = 100) -> list[WebhookDelivery]

counts

counts() -> dict[str, int]

redeliver

redeliver(delivery_id: str | None = None) -> int

Move one dead delivery (or all of them) back to pending with a fresh attempt budget.

WebhookDelivery

Bases: BaseModel

id instance-attribute

id: str

tenant_id instance-attribute

tenant_id: str

endpoint_id instance-attribute

endpoint_id: str

event_id instance-attribute

event_id: str

event_type instance-attribute

event_type: str

status instance-attribute

status: str

attempts instance-attribute

attempts: int

next_attempt_at instance-attribute

next_attempt_at: datetime

last_error instance-attribute

last_error: str | None

created_at instance-attribute

created_at: datetime

delivered_at instance-attribute

delivered_at: datetime | None

DeliveryRun dataclass

DeliveryRun(delivered: int = 0, retrying: int = 0, dead: int = 0)

delivered class-attribute instance-attribute

delivered: int = 0

retrying class-attribute instance-attribute

retrying: int = 0

dead class-attribute instance-attribute

dead: int = 0

endpoints_from_config

endpoints_from_config(items: Iterable[Mapping[str, Any]]) -> list[WebhookEndpoint]

Build endpoints from the webhooks: list in dpdpkit.yaml. Secrets come from the environment (secret_env) so they never sit in the config file.

ConsentRequiredHTTP

ConsentRequiredHTTP(purpose: str)

Bases: HTTPException

Raised by require_consent. Rendered as the contract's Error body once the kit is installed on the app; otherwise FastAPI renders it as {"detail": {...}} with the same code.