Docs / API
API and webhooks overview
1PVN exposes 13 HTTP routes split across three layers: public (no auth, IP rate-limited), staff-gated (session + role required), and system webhooks (secret-verified, provider-triggered). Twelve are documented below; the thirteenth, POST/GET /api/inngest, is the Inngest serve endpoint used internally by the workflow platform and is not a callable integration surface. Every endpoint is multi-tenant-aware — the tenant is resolved from the request subdomain — and all rate limits degrade gracefully when Redis is unavailable.
Purpose: Reference for integrators, tooling authors, and ops staff who call endpoints directly or wire up third-party webhook delivery.
Audience: API integrators, engineers building client tools, ops teams monitoring infrastructure, and the teams at Documenso, Resend, Sentry, and Telegram that send inbound webhooks.
Prerequisites: Familiarity with HTTP status codes and JSON. For webhook setup, access to your provider's webhook configuration UI and the ability to set Fly.io secrets.
Overview
The HTTP surface is divided into three categories:
| Layer | Auth | Rate limit keyed on | Typical use |
|---|---|---|---|
| Public | None | Client IP | Submissions, QR codes, form uploads, campaign responses |
| Staff-gated | Supabase session, role ≥ staff | User ID | Document export, presigned upload URLs, file streaming |
| System webhooks | Secret header, provider-specific | None (provider controls retries) | Signature events, email delivery tracking, error alerts, bot commands |
Every request is matched to a tenant via the Host (or x-forwarded-host) header before any business logic runs. Requests from app.1pvn.com (platform console) have no tenant context. Requests from gepp.1pvn.com resolve to the GeoPP tenant.
A fourth layer — the bearer-authenticated Workforce API (
/api/workforce/*) for automated agents, plus the1pvnagent CLI — is documented separately in Workforce API and agent CLI.
Endpoint categories
Public endpoints
These endpoints accept requests without a user session. Abuse is bounded by IP-based sliding-window rate limits powered by Upstash Redis. If Redis is unavailable, the limit is bypassed (allow-all) — no request is rejected solely because of a Redis outage.
GET /api/health
Fly.io calls this every 30 seconds to decide whether an instance stays in the load-balancer rotation. Returns a JSON object with an overall status and one entry per dependency.
Response (HTTP 200 when healthy, 503 when unhealthy):
{
"status": "ok",
"timestamp": "2026-07-05T12:34:56.789Z",
"version": "1.2.3",
"environment": "production",
"components": {
"db": { "status": "healthy" },
"auth": { "status": "healthy" },
"cache": { "status": "unconfigured" },
"storage": { "status": "unconfigured" },
"email": { "status": "unconfigured" },
"workflows": { "status": "unconfigured" },
"signatures": { "status": "unconfigured" },
"ai": { "status": "unconfigured" }
}
}
Gate rule: if either db or auth reports unhealthy, the response is HTTP 503 and Fly removes the instance from rotation. Non-critical components (cache, storage, etc.) being unconfigured or degraded does not cause removal.
Component status values: healthy | degraded | unhealthy | unconfigured
Rate limit: None.
POST /api/friction
Captures lightweight UX friction signals (abandoned forms, repeated errors, unclear steps). Stores only context metadata — no form values and no PII.
Request body (JSON):
{
"module": "consultations",
"route": "/consultations/new",
"step": "attendees",
"signalType": "abandoned_form"
}
Responses: { "ok": true } on success · 400 for invalid JSON or schema · 429 for rate limit · 500 for storage failure.
Rate limit: 30 signals per IP per hour.
GET /api/qr?id=<attendeeId>
Returns a 320×320 px PNG QR code encoding the attendee check-in URL for a specific attendee. Email confirmations embed this as <img src="/api/qr?id=…"> so the QR renders in Gmail and Outlook, which strip data: URIs.
Query params: id — UUID, required.
Response: PNG binary image. Cache-Control: public, max-age=31536000, immutable.
QR payload encodes: https://<tenant-subdomain>.1pvn.com/checkin?id=<attendeeId>
Errors: 400 for non-UUID id · 404 if subdomain cannot be resolved from the request host.
Rate limit: None (image is already public; the attendee ID contains no PII).
Screenshot: attendee confirmation email showing the QR code image rendered inline.
GET /api/comms/respond?token=<token>&value=<response>
Records a campaign response (RSVP, rating, choice, or free-text) from an email link. The response token is a signed JWT with a 30-day expiry minted by the campaigns module.
Query params: token — JWT, required · value — string, max 500 characters, required.
Response: Redirect to /respond/thanks on success.
Errors: 400 for missing params or value over 500 characters · 401 for invalid or expired token · 429 for rate limit · 500 for storage failure.
Rate limit: 20 responses per IP per hour.
Example link in email:
https://gepp.1pvn.com/api/comms/respond?token=eyJ…&value=yes
POST /api/uploads/public
Accepts a file upload from a public form submission and stores it in Cloudflare R2. The form must exist in the database and have isPublic = true — this gate prevents arbitrary writes to R2.
Request: multipart/form-data with fields file (the file) and slug (the form's slug).
Allowed types: application/pdf, image/png, image/jpeg, image/webp.
Max size: 10 MB.
Response: { "fileKey": "…", "fileName": "…" }.
Error messages:
| Condition | Status | Message |
|---|---|---|
| Missing slug | 400 | "Missing form slug" |
| Form not found or not public | 404 | "Form not found" |
| No file in request | 400 | "No file provided" |
| File over 10 MB | 400 | "File too large (max 10MB)" |
| Wrong file type | 400 | "Use a PDF or image (PNG, JPG, WebP)" |
| Rate limit | 429 | "Too many uploads. Please try again later." |
| Storage failure | 500 | "Upload failed. Please try again later." |
Rate limit: 20 uploads per IP per hour.
Staff-gated endpoints
All staff-gated endpoints require a valid Supabase session with role ≥ staff. A session cookie set by the login flow is enough in the browser. For CLI or server-to-server calls, pass Authorization: Bearer <access_token>.
Role check behavior:
- No session → HTTP 401
- Session with insufficient role → HTTP 403
POST /api/uploads/presigned
Generates a temporary S3-compatible signed URL for staff to upload a file directly to Cloudflare R2. The URL expires in one hour.
Request body (JSON):
{
"filename": "report.pdf",
"contentType": "application/pdf",
"module": "documents",
"entityId": "550e8400-e29b-41d4-a716-446655440000"
}
Allowed modules: submissions, documents, stakeholders.
Allowed content types: PDF, DOCX, DOC, PNG, JPEG, WebP, CSV, Excel (XLS, XLSX).
Response: { "uploadUrl": "https://…", "fileKey": "…" }
Errors: 400 for missing fields, invalid module, or unsupported content type · 401 / 403 for auth · 429 for rate limit · 503 if storage is not configured.
Rate limit: 30 uploads per user per hour (keyed on user ID, not IP).
Two-step upload example:
# Step 1 — request a signed URL (requires auth)
curl -X POST https://gepp.1pvn.com/api/uploads/presigned \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"filename":"report.pdf","contentType":"application/pdf","module":"documents","entityId":"550e8400-e29b-41d4-a716-446655440000"}'
# Response: { "uploadUrl": "https://r2.../...", "fileKey": "documents/550e8400-…/report.pdf" }
# Step 2 — upload directly to R2 (no auth header needed; URL is self-authenticating)
curl -X PUT "https://r2.cloudflare.com/…?<signed-params>" \
-H "Content-Type: application/pdf" \
--data-binary @report.pdf
GET /api/export/pdf?id=<docId>
Generates and downloads a formatted PDF for a document record. The PDF includes a header with the 1PVN logo and date, document type and status, title, content (headings and paragraphs), version history (up to 10 versions), and page numbers.
Query params: id — document UUID, required.
Response: PDF binary with Content-Disposition: attachment; filename="<title>.pdf".
Errors: 400 for missing ID · 401 / 403 for auth · 404 for document not found · 429 for rate limit.
Rate limit: 10 exports per user per hour (PDF generation is CPU-heavy).
GET /api/documents/[id]/file
Streams the stored PDF for a document from Cloudflare R2. Used by the in-app signature field placement editor (react-pdf) to render the document for drag-and-drop field placement. The response is Cache-Control: private, no-store.
Route param: id — document UUID.
Response: PDF binary stream.
Errors: 401 / 403 for auth · 404 for document not found, archived, or having no stored file · 429 for rate limit · 503 if the database is unavailable.
Rate limit: 60 downloads per user per hour.
Authentication and roles
Public endpoints
No authentication. Identity is tracked by IP address extracted from the x-forwarded-host header (set by Fly.io) or the standard Host header.
Staff-gated endpoints
Requires a valid Supabase session (JWT). The backend calls requireRole("staff", { module: "…" }) which:
- Calls
getUser()to validate the session. - Checks
ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY.staff(hierarchy:public0 <lgu1 <staff2 <reviewer3 <lead4 <admin5). - Returns the authenticated user on success or throws an
AuthErroron failure. - Tags Sentry with the module and user ID for attribution.
Role is per-tenant: the same person can be admin at one chamber and staff at another.
Webhook endpoints
Each provider uses a different verification method. All webhook handlers fail closed in production — missing secrets return an error rather than bypassing verification.
| Webhook | Header | Method |
|---|---|---|
| Documenso | x-documenso-secret | Plain-text, constant-time compare |
| Resend | svix-signature + svix-timestamp | HMAC-SHA256 of <timestamp>.<body>, 5-min replay window |
| Sentry | sentry-hook-signature | HMAC-SHA256 of body |
| Telegram | x-telegram-bot-api-secret-token | Plain-text, constant-time compare; also checks chat.id |
Rate limiting
All rate limits use Upstash Redis with a sliding-window algorithm. If Redis is unavailable, the check returns success: true (requests are allowed through). Rate-limit exceeded returns HTTP 429.
| Endpoint | Limit | Window | Keyed on |
|---|---|---|---|
/api/friction | 30 | 1 hour | IP |
/api/comms/respond | 20 | 1 hour | IP |
/api/uploads/public | 20 | 1 hour | IP |
/api/uploads/presigned | 30 | 1 hour | User ID |
/api/export/pdf | 10 | 1 hour | User ID |
/api/documents/[id]/file | 60 | 1 hour | User ID |
Multi-tenant host resolution
Every request is routed to a tenant before any business logic runs. The resolver reads the x-forwarded-host header (Fly.io sets this) or falls back to Host.
Classification logic:
- Extract the subdomain from the host (e.g.,
gepp.1pvn.com→gepp). - Classify:
- Tenant subdomain — single-label subdomain (
gepp,eccp,demo-chamber) → look up in thetenantstable. - Platform subdomain — reserved labels (
app,www) or nested subdomains → no tenant context (console only). - Foreign host —
*.fly.dev,localhost, or anything else → may fall back toTENANT_FALLBACK_SUBDOMAINenv var if set (development and demo only; unset in production).
- Tenant subdomain — single-label subdomain (
- If no tenant resolves and no fallback is configured, queries fail closed (no tenant context = no data).
Webhook handlers determine tenant from event context (for example, the Documenso handler looks up the document's tenant by external_submission_id; it does not rely on the request host).
Webhooks
Documenso — e-signature events
Endpoint: POST /api/webhooks/documenso
Verification: x-documenso-secret header must match DOCUMENSO_WEBHOOK_SECRET. Comparison is constant-time. In production, a missing secret returns HTTP 500 (fail-closed). In non-production, verification is skipped with a warning.
Handled events:
| Documenso event | Internal status | What happens |
|---|---|---|
DOCUMENT_SENT | sent | signatures.status set to 'sent' |
DOCUMENT_SIGNED | partially_signed | signatures.status set to 'partially_signed'; signer's signedAt timestamp recorded |
DOCUMENT_COMPLETED | completed | Signed PDF downloaded from Documenso and stored to R2; document locked (is_locked = true); opportunity owner notified |
Ignored events (acknowledged with HTTP 200 to stop Documenso retries): CREATED, OPENED, REJECTED, CANCELLED.
Example payload (DOCUMENT_COMPLETED):
{
"event": "DOCUMENT_COMPLETED",
"payload": {
"id": 12345,
"status": "COMPLETED",
"recipients": [
{ "email": "alice@eccp.com", "signingStatus": "SIGNED", "signedAt": "2026-07-05T10:30:00Z" },
{ "email": "bob@example.com", "signingStatus": "SIGNED", "signedAt": "2026-07-05T11:15:00Z" }
]
}
}
Screenshot: Documenso webhook configuration UI showing the webhook URL and secret field.
Resend — email delivery tracking
Endpoint: POST /api/webhooks/resend
Verification: svix-signature and svix-timestamp headers. The signature is HMAC-SHA256 of <timestamp>.<raw body> encoded as base64. Timestamps older than 5 minutes are rejected to prevent replay attacks.
Secret: RESEND_WEBHOOK_SECRET. In production, a missing secret returns HTTP 500. In non-production, verification is skipped with a warning.
Handled events:
| Resend event | Status stored | Where |
|---|---|---|
email.delivered | delivered | email_log |
email.opened | opened | email_log + campaign_recipients |
email.clicked | clicked | email_log + campaign_recipients |
email.bounced | bounced | email_log + campaign_recipients |
email.complained | bounced | email_log + campaign_recipients |
Unhandled events are acknowledged with HTTP 200 (no retry). Campaign recipient tracking (campaign_recipients) is best-effort — an error there does not fail the webhook response.
Sentry — error and issue alerts
Endpoint: POST /api/webhooks/sentry
Verification: sentry-hook-signature header. HMAC-SHA256 of the raw body, compared in constant time. In production, a missing secret returns HTTP 500. In non-production, verification is skipped.
Secret: SENTRY_WEBHOOK_SECRET.
Processing:
- Classifies severity:
fatallevel orseverity=criticaltag →critical;errorlevel →error; everything else →warning. - Classifies layer: checks issue
tags['layer'], then culprit path patterns (/api/→api_route,action→server_action,middleware→middleware, default →server_component). - Logs to
app_errorstable withsentryEventId,errorType,message,layer,path,severity, andmetadata. - Forwards to Telegram with severity-appropriate alert level (
critical→ critical alert,error→ medium,warning→ low).
Response: { "received": true, "logged": true, "severity": "error" } (example).
Telegram — bot commands and alerts
Endpoint: POST /api/webhooks/telegram
Verification:
x-telegram-bot-api-secret-tokenheader must matchTELEGRAM_WEBHOOK_SECRET(constant-time compare). In production, a missing secret returns HTTP 401.message.chat.idmust matchTELEGRAM_ALERT_CHAT_ID. Messages from unauthorized chats are silently ignored (HTTP 200 returned to prevent Telegram retries).
Always returns HTTP 200 — Telegram retries on any non-200 response.
Handled commands (routed asynchronously, fire-and-forget):
| Command | What it does |
|---|---|
/status | Infrastructure health: DB, Redis, recent error rates |
/help | Lists available commands |
/infra | Deployment info: regions, Fly resources |
/errors | Recent errors from app_errors table |
/fix | Apply hotfix for critical outages |
/bugs | Recent bugs filed in the issues module |
/deals | Pipeline opportunity summary |
/approve | Approval queue status |
/auth | Current session / identity info |
Alert forwarding: the Sentry webhook handler calls sendAlert() on the Telegram client after logging to app_errors. The Telegram client uses TELEGRAM_BOT_TOKEN to send messages via the Bot API and TELEGRAM_ALERT_CHAT_ID to target the ops channel.
Screenshot: Telegram ops channel showing a
/statuscommand response.
Health checks
Fly.io polls /api/health every 30 seconds using its [http_service.checks] configuration. The response is always JSON — the machine either stays in rotation (HTTP 200, status: "ok") or is removed (HTTP 503, status: "unhealthy").
Decision rule: only db and auth components drive the HTTP status code. Non-critical components (cache, storage, email, workflows, signatures, ai) are reported for observability but do not affect the 200/503 decision.
If an instance is removed: Fly restarts it automatically. If the problem persists across all instances, the deployment is unhealthy — check Supabase status and your environment variables.
Examples
Test the health endpoint
curl https://gepp.1pvn.com/api/health
Expected: HTTP 200 with "status": "ok" when all is well.
Submit a friction signal
curl -X POST https://gepp.1pvn.com/api/friction \
-H "Content-Type: application/json" \
-d '{"module":"pipeline","route":"/pipeline/new","step":"details","signalType":"abandoned_form"}'
Expected: { "ok": true }.
Get a presigned upload URL (staff)
# Requires a valid session token from Supabase Auth
curl -X POST https://gepp.1pvn.com/api/uploads/presigned \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"filename":"proposal.pdf","contentType":"application/pdf","module":"documents","entityId":"<uuid>"}'
Expected: { "uploadUrl": "https://…", "fileKey": "documents/<uuid>/proposal.pdf" }.
Upload a file to R2 with the signed URL
curl -X PUT "<uploadUrl-from-previous-step>" \
-H "Content-Type: application/pdf" \
--data-binary @proposal.pdf
Expected: HTTP 200 with an empty body (R2 returns no body on success).
Best practices
For public endpoint callers
- Implement retry with exponential backoff for 5xx errors — infrastructure failures are transient.
- Handle 429 gracefully — back off and surface a "too many requests" message to the user rather than retrying immediately.
- Do not embed tracking codes or form slugs in client-side code that is not yours — if your IP gets rate-limited, all users behind that IP are affected.
For webhook senders (Documenso, Resend, Sentry, Telegram)
- Verify signatures first, before parsing the JSON body — never process a payload that hasn't been authenticated.
- Return HTTP 200 as soon as verification passes, even if your processing fails asynchronously. Providers retry on non-200; duplicate retries on a slow but successful handler cause duplicate side effects.
- Implement idempotency — providers may retry a webhook event. Use the event's
email_id,external_submission_id, or similar field to detect duplicates.
For staff-gated endpoint callers
- Presigned URLs are single-use and expire in one hour. Request a fresh URL for each upload session — do not cache and reuse them.
- Request the presigned URL immediately before the upload, not minutes earlier. A URL that expires before the upload starts will result in a 403 from R2.
Warnings
- Never commit webhook secrets (
DOCUMENSO_WEBHOOK_SECRET,RESEND_WEBHOOK_SECRET,SENTRY_WEBHOOK_SECRET,TELEGRAM_WEBHOOK_SECRET) to Git. Store them as Fly.io secrets:flyctl secrets set KEY=value. - Webhook verification is fail-closed in production. A missing secret returns 500, not 200 — it is deliberate that unverified webhook payloads cannot enter the system.
- Public endpoint IP rate limits cover shared addresses. A corporate network, VPN, or school WiFi with many users will share one IP and may hit limits faster than expected. If this is a recurring problem, contact Ops.
- QR codes are public. Anyone who receives the confirmation email can share the QR image. Check-in is idempotent — scanning twice marks the attendee as attended once, it does not create a second check-in.
- Subdomain routing is the tenant boundary. Misconfigured DNS or reverse-proxy headers that forge the subdomain can route requests to the wrong tenant. Validate your DNS configuration at the infrastructure layer.
Troubleshooting
Health check returns unhealthy (HTTP 503)
Symptom: Fly removes instance from the load balancer; /api/health returns "status": "unhealthy".
Cause: db or auth component is reporting unhealthy.
Fix:
- Check the Supabase status page for the project region.
- Verify
DATABASE_URLandNEXT_PUBLIC_SUPABASE_URLare set correctly:flyctl secrets list | grep SUPABASE. - Restart the app:
flyctl apps restart <app-name>. - If the problem persists, check Fly logs:
flyctl logs.
Rate limit exceeded (HTTP 429) on a public endpoint
Symptom: Response body contains "Rate limit exceeded" or "Too many uploads. Please try again later.".
Cause: Same IP has crossed the per-hour threshold.
Fix:
- Immediate: Wait for the 1-hour sliding window to clear, or switch to a different network (mobile hotspot).
- Integrators: Use staff-gated presigned uploads (
/api/uploads/presigned) which are scoped per-user (30/hour) rather than per-IP (20/hour). - Persistent issues: Contact Ops via Telegram
/statusto check if one IP is hammering the endpoint and temporarily adjust via Redis.
Webhook signature verification fails (HTTP 401)
Symptom: Webhook handler returns "Invalid signature" or "Invalid secret"; provider retries the webhook repeatedly.
Cause:
- Secret env var not set or mismatched between provider and app.
- Provider rotated the secret but the app was not updated.
- Body modified in transit (uncommon).
Fix:
- Check that the secret is set:
flyctl secrets list | grep WEBHOOK. - Open the provider UI (Documenso / Resend / Sentry) and confirm the secret matches.
- To rotate: regenerate the secret in the provider UI, update Fly:
flyctl secrets set DOCUMENSO_WEBHOOK_SECRET=<new>, then redeploy. - Temporarily disable the webhook in the provider UI while rotating to stop retries.
Presigned URL expired before upload completed (HTTP 403 from R2)
Symptom: R2 returns HTTP 403 after you call PUT <uploadUrl>.
Cause: The signed URL expired (1-hour window) or was already used.
Fix:
- Call
/api/uploads/presignedagain to get a fresh URL. - Start the upload immediately — do not wait between requesting and using the URL.
Documenso webhook received but document status not updated
Symptom: Webhook logs show success: true but signatures.status is still sent.
Cause: Documenso fires DOCUMENT_SIGNED when each individual signer completes. The final lock-and-download only happens on DOCUMENT_COMPLETED (all signers done).
Fix:
- Check the Documenso webhook logs in Settings → Webhooks for the event type.
- If you see only
DOCUMENT_SENTand noDOCUMENT_SIGNED, the signer has not yet opened or signed the email. - Use Documenso's "Resend" feature to send the signer a reminder.
Resend webhook not updating campaign open/click rates
Symptom: Campaign open/click rates show 0% despite successful delivery.
Cause:
- Resend webhook not configured or secret mismatch.
- Email clients with privacy protection (Apple Mail Privacy Protection, many corporate clients) do not fire open/click tracking pixels.
Fix:
- Check Resend dashboard → Webhook Logs for recent delivery events.
- Verify
RESEND_WEBHOOK_SECRETis set in Fly secrets. - Expect open and click rates of 10–30% in practice — privacy features suppress most tracking at the mail-client level. Delivery counts are more reliable.
FAQ
Q: Can I use the same presigned URL for multiple files?
No. Each presigned URL is single-use and bound to one specific file path. Call /api/uploads/presigned once per file.
Q: Do all endpoints require HTTPS?
Yes. 1PVN redirects HTTP to HTTPS at the Fly proxy level. Webhook senders must use https:// URLs.
Q: Can I test webhooks locally?
Webhook endpoints require a public HTTPS URL with valid DNS. For local testing, use a tunneling service such as ngrok or Cloudflare Tunnel to expose localhost:3000.
Q: What happens if a webhook event is delivered twice?
The app handles most duplicates. Documenso and Resend webhook processing uses external IDs (external_submission_id, email_id) to identify the record, so a retry updates the same row rather than creating a duplicate. Telegram commands are fire-and-forget; a retry would re-run the command.
Q: What is the difference between public and staff file uploads?
/api/uploads/public is IP-limited, accepts only PDF and images (PNG/JPEG/WebP), and requires a public form slug. /api/uploads/presigned is user-limited, also accepts DOCX, DOC, CSV, and Excel, and requires a staff session.
Q: How do I rotate a webhook secret?
Regenerate the secret in the provider UI, then: flyctl secrets set <SECRET_KEY>=<new-value>. Redeploy the app. The old webhook URL will return 401 until the deploy completes — disable the webhook in the provider UI during the transition to stop retries.
Q: What does HTTP 503 on /api/uploads/presigned mean?
Cloudflare R2 storage is not configured in this environment (missing CLOUDFLARE_R2_* env vars). This is expected in local development without storage setup.
Related articles
- System architecture overview — how subdomains map to tenants, the platform services behind these endpoints
- Roles and access — the role hierarchy, what staff/reviewer/lead/admin can do, and how new users are activated
- Sending a document for e-signature — the staff-facing workflow behind the Documenso webhook
- Dedicated references for storage/uploads, rate limiting, and Inngest workflows are planned for Wave 3 — see the roadmap.