Overview
Shonin is an open source, human-in-the-loop approval API. Send an approval request to any email address and wait for a human decision before your automation continues. Approvers need no account: they just click a link.
https://shonin.dev/api/v1All requests must include an Authorization: Bearer <api_key> header, except the /v1/decide/:token links in approval emails, which approvers open and which need no key.
Shonin is MIT licensed. Try the hosted demo at shonin.dev, or run your own copy (see ).
Quickstart
- Sign in with your email at /login. Your API key is on your dashboard and is emailed to you.
- Send an approval request. Use your own email as the approver: the hosted demo only sends to your account email.
curl -X POST https://shonin.dev/api/v1/approvals \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"action": "Deploy to production",
"approver_email": "you@example.com"
}'- Open the email and click Approve, then read the result with the
idfrom the response:
curl https://shonin.dev/api/v1/approvals/APPROVAL_ID \
-H "Authorization: Bearer sk_your_api_key"The status field changes from pending to approved or rejected.
Authentication
Pass your API key as a Bearer token in the Authorization header on every request. Sign in at /login to see your key.
curl -H "Authorization: Bearer sk_your_api_key" \
https://shonin.dev/api/v1/approvals/APPROVAL_IDCreate Approval
POST/v1/approvalsCreates a new approval request and sends an email to the approver with Approve and Reject buttons. Returns immediately. The approval stays pending until the approver decides.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
| action | string | required | A short description of what needs approval. Shown prominently in the email. |
| approver_email | string | required | The email address of the person who will approve or reject. |
| context | string | optional | Optional extra context displayed in the email below the action. |
| webhook_url | string | optional | Public https URL to POST the decision to when the approver clicks Approve or Reject. Private and reserved addresses are rejected with a 400. |
| expires_in_hours | number | optional | How many hours before the approval link expires. Defaults to 24. |
| command_type | string | optional | What kind of action this is, for example git_push_force or sql_drop. Drives the risk banner in the email. Inferred from action when omitted. See Risk Levels. |
| files | array | optional | Files the action touches, for example [{ "path": "app/route.ts", "status": "modified" }]. Status is modified, added, deleted or renamed. |
| diff | string | optional | A diff to show the approver. Truncated at 50KB. |
Example request
curl -X POST https://shonin.dev/api/v1/approvals \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"action": "Deploy to production",
"approver_email": "cto@company.com",
"context": "PR #247 merged, 3 files changed",
"webhook_url": "https://yourapp.com/webhooks/shonin"
}'Example response 201 Created
{
"id": "a1b2c3d4-...",
"status": "pending",
"created_at": "2026-03-21T18:00:00Z",
"expires_at": "2026-03-22T18:00:00Z"
}Get Approval
GET/v1/approvals/:idReturns the current state of an approval. Use this to poll for a decision if you are not using webhooks. Only approvals belonging to the authenticated account are returned.
Example request
curl https://shonin.dev/api/v1/approvals/a1b2c3d4 \
-H "Authorization: Bearer sk_your_api_key"Example response 200 OK
{
"id": "a1b2c3d4-...",
"action": "git push origin main --force",
"context": "Remote diverged after a rebase",
"approver_email": "cto@company.com",
"status": "approved",
"webhook_url": null,
"command_type": "git_push_force",
"risk_level": "DESTRUCTIVE",
"risk_bullets": [
"Will overwrite upstream commits",
"Bypasses branch protection rules"
],
"files": [{ "path": "app/route.ts", "status": "modified" }],
"expires_at": "2026-03-22T18:00:00Z",
"decided_at": "2026-03-21T18:45:00Z",
"created_at": "2026-03-21T18:00:00Z"
}Risk Levels
When an approval has a command_type, the email opens with a risk banner so the approver can see how reversible the action is. If you leave command_type out, Shonin infers it from action when it recognizes commands such as git push --force, git reset --hard, rm, drop table or a migration.
| command_type | Level | Shown to the approver |
|---|---|---|
| git_push_force | DESTRUCTIVE | Will overwrite upstream commits. Bypasses branch protection rules. |
| git_reset_hard | DESTRUCTIVE | Local changes will be permanently lost. |
| rm | DESTRUCTIVE | Files cannot be recovered from trash. |
| sql_drop | DESTRUCTIVE | Table data is permanently deleted. |
| sql_migration | HIGH | Schema changes may be irreversible. |
| git_push | LOW | Reversible via git revert. |
| git_commit | LOW | Reversible via git reset. |
| anything else | LOW | No banner text. |
Create Decision
POST/v1/decisionsAsk a multiple-choice question instead of a yes or no. The respondent gets an email with one button per option and picks one.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
| question | string | required | The question to ask. |
| options | array | required | 2 to 10 options, each { "key": "prod", "label": "Production" }. A key is up to 16 characters and a label up to 200. |
| respondent_email | string | required | The email address of the person who will answer. |
| context | string | optional | Optional extra context shown in the email. |
| webhook_url | string | optional | Public https URL to POST the answer to. |
| expires_in_hours | number | optional | How many hours before the links expire. Defaults to 24. |
Example request
curl -X POST https://shonin.dev/api/v1/decisions \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"question": "Which environment should we deploy to?",
"options": [
{ "key": "staging", "label": "Staging" },
{ "key": "prod", "label": "Production" }
],
"respondent_email": "cto@company.com"
}'Example response 201 Created
{
"id": "e5f6a7b8-...",
"status": "pending",
"created_at": "2026-03-21T18:00:00Z",
"expires_at": "2026-03-22T18:00:00Z"
}Get Decision
GET/v1/decisions/:idReturns the current state of a decision. status is pending or decided, and chosen_key holds the key of the option that was picked.
{
"id": "e5f6a7b8-...",
"status": "decided",
"chosen_key": "prod",
"decided_at": "2026-03-21T18:45:00Z",
"expires_at": "2026-03-22T18:00:00Z",
"created_at": "2026-03-21T18:00:00Z"
}Webhooks
Set webhook_url on an approval or a decision and Shonin sends a POST with a JSON body once the decision is recorded.
Approval
{
"id": "a1b2c3d4-...",
"status": "approved",
"decided_at": "2026-03-21T18:45:00Z"
}Decision
{
"id": "e5f6a7b8-...",
"status": "decided",
"chosen_key": "prod",
"decided_at": "2026-03-21T18:45:00Z"
}- The URL must be a public https address. Private and reserved addresses are rejected with a 400 when you create the request.
- Shonin waits 5 seconds for a response, does not follow redirects, and sends each webhook once with no retries.
How Decisions Work
You never call the decide endpoint yourself. It sits behind the links in the email, and it is built so the code that asks for approval cannot give it.
- The approve and reject links exist only in the email to the approver. The API never returns them, so your code cannot approve its own request.
- Opening a link never decides. It leads to a confirm page, and the decision is recorded only when the approver confirms, which sends a POST. Mail scanners and link previews that open every link cannot decide.
- Links are single-use and expire after 24 hours unless you set
expires_in_hours.
Errors
All errors return a JSON object with an error field containing a human-readable message.
{ "error": "Invalid API key" }Error codes
| Status | Meaning | Common cause |
|---|---|---|
| 400 | Validation error | A required field is missing, a value has the wrong type, or webhook_url is not a public https address. |
| 401 | Invalid API key | Missing or incorrect Authorization header. |
| 404 | Not found | The ID does not exist or belongs to a different account. |
Hosted demo only
The hosted demo is capped to protect a shared email allowance. Self-hosted instances do not apply these limits.
| Status | Meaning | Common cause |
|---|---|---|
| 403 | Recipient not allowed | approver_email or respondent_email is not the email on your account. |
| 429 | Daily limit reached | Your key has used its requests for the day. |
| 503 | Demo budget used | The shared email budget for today is used up. Try again tomorrow. |
Self-Hosting
Shonin is MIT licensed and runs on Next.js, Supabase and Resend. A self-hosted instance has no request limits, and it serves this same documentation at /docs.
- Clone the repository and run
npm install. - Create a Supabase project and run
supabase/schema.sqlin its SQL editor. - Copy
.env.exampleto.env.localand fill in your Supabase and Resend values. SetEMAIL_FROM_DOMAINto your verified Resend domain. - In Supabase, set your app URL as the Site URL and as a redirect URL so login works.
- Insert your first API key, then run
npm run dev.
The full steps, including the SQL for your first key, are in the README on GitHub.