Taifa MailTaifa Mail Docs
API Reference

Domain Transfers API

API reference for transferring a verified domain to another Taifa Mail account or workspace.

Base URL: https://govconnect.ke/v1

All endpoints require authentication via API Key or JWT cookie. API keys are passed as Authorization: Bearer tfm_k_....

A transfer is a two-sided handshake: the source owner initiates, the recipient accepts, then a 24-hour safety window runs before ownership moves. See the Transfer a Domain guide for the full flow.

Domain transfer may be gated during rollout. When the feature is disabled, these endpoints return 404.

The transfer object

{
  "id": "b2c3d4e5-6789-01ab-cdef-234567890abc",
  "domain_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  "domain_name": "notifications.yourapp.com",
  "source_user_id": "11111111-1111-1111-1111-111111111111",
  "source_org_id": "22222222-2222-2222-2222-222222222222",
  "source_label": "owner@agency.com",
  "target_email": "client@company.com",
  "target_user_id": null,
  "target_org_id": null,
  "status": "pending",
  "note": "Handing this back to you.",
  "cooloff_until": null,
  "initiated_at": "2026-05-30T10:00:00Z",
  "accepted_at": null,
  "completed_at": null,
  "expires_at": "2026-06-06T10:00:00Z"
}

Transfer statuses

StatusMeaning
pendingInitiated, waiting for the recipient to accept. Expires 7 days after initiated_at.
acceptedRecipient accepted. The 24-hour safety window is running (cooloff_until).
completedSafety window ended. Ownership has moved.
declinedRecipient declined the request.
cancelledSource cancelled the request while it was pending.
reversedSource stopped the transfer during the safety window. Ownership never moved.
expiredThe 7-day request window passed without acceptance.

Initiate a transfer

POST /v1/domains/{domain_id}/transfer

Starts a transfer of a domain you own. Runs the preconditions and emails the recipient an accept link. Rate limited to 5 requests per minute.

Request body:

{
  "target_email": "client@company.com",
  "note": "Handing this back to you."
}
FieldTypeRequiredDescription
target_emailstringYesEmail of the recipient. Must match an existing Taifa Mail account.
notestringNoOptional message shown to the recipient. Max 1000 characters.

Response (200): the transfer object with status: "pending".

curl -X POST https://govconnect.ke/v1/domains/a1b2c3d4-.../transfer \
  -H "Authorization: Bearer tfm_k_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target_email": "client@company.com", "note": "Handing this back to you."}'

Preconditions. The request is rejected with 409 Conflict and a code when:

codeMeaning
transfer_activeA pending or accepted transfer already exists for this domain.
broadcasts_in_flightA broadcast on this domain's senders is sending or scheduled. The offending items are listed under blockers.
scheduled_emailsThis domain has scheduled transactional emails.
self_transferThe target email is your own account.

target_not_found returns 404 when no Taifa Mail account uses the target email.


List incoming transfers

GET /v1/domain-transfers/incoming

Returns active (pending or accepted) transfers addressed to the authenticated user's email.

curl https://govconnect.ke/v1/domain-transfers/incoming \
  -H "Authorization: Bearer tfm_k_YOUR_API_KEY"

Response: an array of transfer objects.


List outgoing transfers

GET /v1/domain-transfers/outgoing

Returns transfers the authenticated user has initiated (most recent first).

Response: an array of transfer objects.


Check accept eligibility

GET /v1/domain-transfers/accept-eligibility

Reports whether the authenticated user can accept an incoming transfer right now. The dashboard calls this before showing the Accept button so the recipient sees a plan domain-limit block up front, instead of hitting it on the accept call.

curl https://govconnect.ke/v1/domain-transfers/accept-eligibility \
  -H "Authorization: Bearer tfm_k_YOUR_API_KEY"

Response (200):

{
  "can_accept": false,
  "code": "domain_limit",
  "message": "You've reached the maximum of 1 domain on the free plan. Upgrade your plan to add more.",
  "current_domains": 1,
  "domain_limit": 1
}
FieldTypeDescription
can_acceptbooleantrue if the user can accept an incoming transfer now.
codestring | nullReason the user is blocked (domain_limit), or null when can_accept is true.
messagestring | nullHuman-readable explanation to show the user, or null when unblocked.
current_domainsintegerDomains the user currently owns.
domain_limitintegerThe user's plan domain limit (-1 means unlimited).

This check is advisory for the UI. The same limit is still enforced server-side on accept, so a transfer can never push an account past its plan.


Accept a transfer by token

POST /v1/domain-transfers/{token}/accept

Accepts using the one-shot token from the invitation email. Moves the transfer to accepted and starts the 24-hour safety window. The recipient's plan domain limit is enforced here.

Request body:

{
  "target_org_id": "33333333-3333-3333-3333-333333333333",
  "rotate_dkim": false
}
FieldTypeRequiredDescription
target_org_idstringNoWorkspace to receive the domain. Defaults to your oldest workspace.
rotate_dkimbooleanNoMint a fresh DKIM key on transfer. Defaults to false (keep the current key).

Response (200): the transfer object with status: "accepted" and a cooloff_until timestamp.

Errors:

StatuscodeMeaning
404not_foundThe token is invalid or already used.
409not_pendingThe transfer can no longer be accepted.
410expiredThe 7-day request window has passed.
403wrong_recipientThe transfer was sent to a different account.
403org_access_lostYou no longer belong to the chosen workspace. Pick another and retry.

Accept a transfer by id

POST /v1/domain-transfers/by-id/{transfer_id}/accept

Accepts from the authenticated dashboard without the email token. The caller is matched to the transfer by their account email. Body and responses match accept by token.


Decline a transfer

POST /v1/domain-transfers/{transfer_id}/decline

Declines a pending transfer addressed to you. The sender is notified.

Response (200): the transfer object with status: "declined".


Cancel a transfer

DELETE /v1/domain-transfers/{transfer_id}

Withdraws a transfer you initiated while it is still pending. To reverse after acceptance, use stop instead.

Response (200): the transfer object with status: "cancelled".

Once a transfer is accepted, DELETE returns 409 with code: "not_pending". Use the stop link during the safety window.


Stop a transfer during the safety window

POST /v1/domain-transfers/stop/{stop_token}

Reverses an accepted transfer during the 24-hour safety window using the stop token from the "stop this transfer" email. Ownership never moves.

Response (200): the transfer object with status: "reversed".

Errors:

StatuscodeMeaning
404not_foundThe stop link is invalid or already used.
409not_stoppableThe transfer is no longer in the safety window.

Admin force transfer

POST /v1/admin/domain-transfers/{domain_id}

Platform-admin override for stuck cases. Skips the email handshake and the safety window: ownership moves immediately. Requires admin re-authentication.

Request body:

{
  "target_email": "client@company.com",
  "rotate_dkim": true
}
FieldTypeRequiredDescription
target_emailstringYesEmail of the recipient account.
rotate_dkimbooleanNoMint a fresh DKIM key on transfer. Defaults to true.

Response (200): the transfer object with status: "completed".

Admin force transfer is immediate and irreversible. It revokes the previous owner's SMTP credentials for the domain and writes an audit entry on both sides.

On this page