Sojourner API docs document a JSON API built using the following tools and technologies: Node.js, TypeSecript, AdonisJS 6, Lucid ORM, Postgresql, and VineJS (validation). This guide covers the following areas:
- Payment system
- Webhook Concurrency Handling
- Queue System
- Real-Time Chat Integration
- Deployment
The subscription system uses a two-phase creation pattern to handle Dodo Payments' checkout session flow where subscription IDs aren't available immediately.
Old Flow (switched from this because dodopayments deprecated the create subscriptions endpoint): Creation of subscriptions happened before payment meaning that when a user was redirected to dodopayments checkout, the subscription_id would be issued at this point.
subscriptions.create → immediate dodoSubscriptionId → save to DB → webhooks updateNew Flow (Current):
checkoutSessions.create → sessionId only → save pending record → webhook populates dodoSubscriptionIdCore Issue: The checkout session API returns an object with sessionId and checkoutUrl, not an object with paymentLink, dodoSubscriptionId, and other useful data like subscription expiry dates. The subscription ID and other information arrive later (after the user completes the payment flow) via webhook, creating a temporal gap of information in the old flow.
Challenge: Services expected immediate dodoSubscriptionId for database lookups, but now must work without it until the webhook arrives.
Create a record with the available information ie sessionId, checkoutUrl, and status set to pending. Then update the relevant tables using the the webhooks payload information once processed.
Location: IndividualSubscriptionService.createIndividualSubscription() / GroupSubscriptionService.createGroupSubscription()
What Happens:
- User initiates subscription attempt
- Call
DodoPaymentService.createIndividualSubscription()orcreateGroupSubscription() - Dodo returns
{ checkoutUrl, sessionId }(No dodoSubscriptionId yet) - Save DB record with:
dodoSessionId= sessionId from DodododoSubscriptionId= nullstatus= 'pending'
- Redirect user to
checkoutUrl
Example - Individual:
const dodoResponse = await this.dodoPaymentService.createIndividualSubscription({
userId,
planType: payload.plan_type,
billingAddress: payload.billing_address,
email: user.email,
name: user.name,
metadata: {
userId: userId.toString(),
subscription_type: 'individual',
},
})
await IndividualSubscription.create({
userId,
dodoSessionId: dodoResponse.sessionId,
dodoSubscriptionId: null, // don't have this yet
planType: payload.plan_type,
status: 'pending',
})Example - Group:
const dodoResponse = await this.dodoPaymentService.createGroupSubscription({
ownerUserId,
planType: payload.plan_type,
totalSeats: payload.total_seats,
billingAddress: payload.billing_address,
email: owner.email,
name: owner.name,
metadata: {
ownerUserId: ownerUserId.toString(),
subscription_type: 'group',
},
})
await GroupSubscription.create({
ownerUserId,
dodoSessionId: dodoResponse.sessionId,
dodoSubscriptionId: null, // don't have this yet
totalSeats: payload.total_seats,
inviteCode,
inviteCodeExpiresAt,
status: 'pending',
planType: payload.plan_type,
})Location: WebhookProcessorService → IndividualSubscriptionService.handleSubscriptionActive() / GroupSubscriptionService.handleSubscriptionActive()
What Happens:
- User completes payment
- Dodo sends
subscription.activewebhook withdodoSubscriptionId - Extract
userId/ownerUserIdfrom webhook metadata - Find subscription by
userId/ownerUserId+whereNull('dodoSubscriptionId') - Populate
dodoSubscriptionId, setstatus = 'active' - Update user tiers
Example - Individual:
async handleSubscriptionActive(
userId: number,
dodoSubscriptionId: string,
expiresAt: string,
trx: TransactionClientContract
): Promise<User> {
const subscription = await IndividualSubscription.query({ client: trx })
.where('user_id', userId)
.whereNull('dodo_subscription_id') // finds the pending one
.preload('user')
.forUpdate()
.firstOrFail()
await subscription.merge({
dodoSubscriptionId, // populates dodo_subscription_id
status: 'active',
expiresAt: DateTime.fromISO(expiresAt),
}).save()
await this.gracePeriodService.clearGracePeriod(userId, trx)
await this.tierService.updateUserTier(userId, 'Payment successful', 'webhook', trx, {
individual_subscription_id: subscription.id,
})
return subscription.user
}Example - Group:
async handleSubscriptionActive(
ownerUserId: number,
dodoSubscriptionId: string,
expiresAt: string,
trx: TransactionClientContract
): Promise<User> {
const groupSubscription = await GroupSubscription.query({ client: trx })
.where('owner_user_id', ownerUserId)
.whereNull('dodo_subscription_id') // finds the pending one
.preload('owner')
.forUpdate()
.firstOrFail()
await groupSubscription.merge({
dodoSubscriptionId, // populates dodo_subscription_id
status: 'active',
expiresAt: DateTime.fromISO(expiresAt),
}).save()
const members = await GroupSubscriptionMember.query({ client: trx })
.where('group_subscription_id', groupSubscription.id)
.where('status', 'active')
// Update tier for all members
await Promise.all(members.map(async (member) => {
await this.gracePeriodService.clearGracePeriod(member.userId, trx)
await this.tierService.updateUserTier(member.userId, 'Group subscription activated', 'webhook', trx, {
group_subscription_id: groupSubscription.id,
})
}))
return groupSubscription.owner
}Dodo Payments webhooks are built following the standard webhooks specification. This provides advantages like reliablity in their shape and verifying the signatures. Dodo payments, through their TypeScript SDK, provides a method to verify incoming hooks. Dodo webhooks also support idempotency because each webhook event contains an eventId.
However, a few challenges arise from Dodo Payments' set up - they do not guarantee the order of hooks and the developer has to handle idempotency to ensure effeciency. It's importnat to understand that these are not short-comings but areas your should accout for when designing your systems. Another challenge is selecting the correct row from the database after saving the webhook.
Dodo Payments does not guarantee webhook delivery order. For every new subscription, Dodo Payments sends the subscription.active (the subscription is now active and recurring charges are scheduled) and subscription.renewed (the first billing cycle was successfully processed). On every subsequent billing cycle, subscription.renewed webhook event continues to arrive. There is no guarantee which of the two arrives first on that initial pair.
This matters because the renewed handler queries by dodoSubscriptionId, which only exists after subscription.active has been processed and written it to the database (let's refer to it as the initialiser).
Solution: Treat handleSubscriptionActive in IndividualSubscriptionService and GroupSubscriptionService as the initialiser — the only handler that uses whereNull('dodo_subscription_id') to locate and activate the pending record. All other lifecycle handlers
(renewed, cancelled, expired, etc.) query by dodoSubscriptionId directly. When renewed arrives before active has been processed,
.firstOrFail() throws, the job queue catches the error, marks the job failed, and reschedules it for retry after a delay (RETRY_DELAYS = [0, 60, 300] seconds). By the time the retry runs, active will have been processed and the dodoSubscriptionId populated, allowing renewed to find the correct row and complete successfully. The two-phase query pattern is what makes the retry work correctly — without it, the retry would still fail.
Sojourner API enforces a one-active-subscription-per-user rule, but users can accumulate multiple historical subscription records over time (e.g. an expired individual plan followed by a new one). Querying by userId alone could match the wrong row.
Solution: The whereNull('dodo_subscription_id') clause solves this by targeting only the record created during the current checkout session — a null dodoSubscriptionId indicates the subscription has been initiated but not yet activated. While this correlates with status = 'pending', the two are not strictly equivalent and whereNull is the more precise signal here.
Location: webhook_processor_service → WebhookService.handleSusbcriptionActive() → IndividualSubscriptionService.handleSubscriptionActive() / GroupSubscriptionService.handleSubscriptionActive()
This handler bridges the gap between the checkout session and the established subscription. Since the database does not yet know the dodoSubscriptionId, look up the pending record by the user's identifier.
The .whereNull('dodo_subscription_id') clause is applied in WebhookProcessorService.handleSubscriptionActive method before calling the service handlers, to confirm a pending record exists and guard against orphan healing.
Query Pattern:
- Individual:
where('user_id', userId)+whereNull('dodo_subscription_id') - Group:
where('owner_user_id', ownerUserId)+whereNull('dodo_subscription_id')
Both handlers then populate dodoSubscriptionId, dodoCustomerId, status, and expiresAt on the located record.
Phase 2: Subscription Lifecycle Updates (subscription.renewed, subscription.cancelled, subscription.expired, subscription.plan_changed, subscription.failed)
Location: IndividualSubscriptionService / GroupSubscriptionService — respective handler methods
All subsequent webhook handlers query by both the user's identifier and the dodoSubscriptionId for a strict match. This prevents a lifecycle event from accidentally targeting the wrong subscription when a user has multiple historical records.
Query Pattern:
- Individual:
where('user_id', userId)+where('dodo_subscription_id', dodoSubscriptionId) - Group:
where('owner_user_id', ownerUserId)+where('dodo_subscription_id', dodoSubscriptionId)
Here is how the system automatically heals when subscription.renewed is processed before subscription.active:
- The
process:webhooksworker picks up therenewedjob and routes it to the handler. - The handler queries by
userId+dodoSubscriptionId. Becausesubscription.activehasn't been processed yet, no row with thatdodoSubscriptionIdexists —.firstOrFail()throws aRowNotFoundException. - The worker catches the error, rolls back the transaction, marks the job
failed, and reschedules it 60 seconds out (RETRY_DELAYS = [0, 60, 300]). - The worker picks up the
activejob, processes it successfully, and writesdodoSubscriptionIdonto the subscription record. - One minute later, the worker retries the
renewedjob. The strict query now finds the row and updates it successfully.
Webhook handlers have no direct link to a subscription record — they only receive what Dodo Payments sends back. Metadata is embedded in the checkout session at creation time in DodoPaymentService.createIndividualSubscription() or DodoPaymentService.createGroupSubscription() and travels with every subsequent webhook for that subscription, giving the handlers the identifiers they need to locate the correct record.
{
userId: userId.toString(),
subscription_type: 'individual'
}{
ownerUserId: ownerUserId.toString(), // ownerUserId is semantically correct and signals you are dealing with a group subscription
subscription_type: 'group'
}Location: webhook_processor_service.ts
Extracts subscription_type, userId/ownerUserId, and dodoSubscriptionId from the payload, then delegates to the appropriate service handler. See webhook_processor_service.ts for the full routing switch.
Handled by IndividualSubscriptionService.handleSubscriptionActive() or GroupSubscriptionService.handleSubscriptionActive(). Locates the pending record via whereNull('dodo_subscription_id') and populates it with the subscription ID, customer ID, status, and expiry.
Handled by IndividualSubscriptionService.handleSubscriptionRenewed() or GroupSubscriptionService.handleSubscriptionRenewed(). Queries by userId/ownerUserId and updates expiresAt.
Other Webhooks (subscription.cancelled, subscription.expired, subscription.failed, subscription.plan_changed)
Same routing pattern — extract identifiers, delegate to the matching service handler. Group handlers additionally receive dodoSubscriptionId to populate defensively if null. See individual handler methods in IndividualSubscriptionService and GroupSubscriptionService.
See the IndividualSubscription Model for shape - app/models/individual_subscription.ts
See the GroupSubscription Model for shape - app/models/group_subscription.ts
If a webhook arrives with no matching pending subscription — because the user completed payment but the server was down and never recorded the session_id in Phase 1 — the system creates a minimal subscription record from the webhook payload before proceeding with activation. See webhook_processor_service.ts -> WebhookService.handleSubscriptionActive() for the healing logic.
- Check metadata includes correct
userId/ownerUserId - Verify handler queries by
userId/ownerUserId, notdodoSubscriptionId - Confirm
dodoSessionIdwas saved in Phase 1
- Check
subscription.activewebhook arrived - Verify handler calls
.merge({ dodoSubscriptionId })and.save() - Check for transaction rollbacks
- Verify
dodo_session_idhas UNIQUE constraint - Check idempotency in webhook processing
- Confirm no race conditions in Phase 1 creation
- Verify metadata has correct
userId/ownerUserId(not switched) - Check webhook processor extracts correct metadata field for group vs individual
User Checkout
↓
DodoPaymentService.createGroupSubscription() / createIndividualSubscription()
↓
Dodo API: checkoutSessions.create()
↓
← { checkoutUrl, sessionId }
↓
DB: Save with dodoSessionId=sessionId, dodoSubscriptionId=null, status='pending'
↓
Redirect user to checkoutUrl
↓
[User completes payment]
↓
Dodo → subscription.active webhook
↓
WebhookService.handleSubscriptionActive()
↓
Extract ownerUserId from metadata
↓
Find subscription: where('owner_user_id', ownerUserId).whereNull('dodo_subscription_id')
↓
GroupSubscriptionService.handleSubscriptionActive()
↓
Populate: dodoSubscriptionId, status='active', expiresAt
↓
Clear grace periods, update tiers
↓
Subscription fully active
[Later: subscription.renewed webhook]
↓
Extract ownerUserId from metadata
↓
Find subscription: where('owner_user_id', ownerUserId)
↓
Populate dodoSubscriptionId if null (defensive)
↓
Update expiresAt, maintain status='active'
↓
Update tiers
↓
Subscription renewed- DodoPaymentService - Added metadata to checkout session creation
- IndividualSubscriptionService - Two-phase creation, userId-based handlers
- GroupSubscriptionService - Two-phase creation, ownerUserId-based handlers
- WebhookProcessorService - Extract userId/ownerUserId, pass to handlers
- Models - Added
dodoSessionId, madedodoSubscriptionIdnullable - Migrations - Added columns, indexes
The decoupled architecture solves the temporal gap between checkout and subscription activation by:
- Saving early with
dodoSessionId(from checkout) - Querying by where(
userId/ownerUserId) and whereNull('dodo_subscription_id') where applicable as stated before. - Populating late with
dodoSubscriptionId(from webhook) - Handling disorder via defensive ID population in all handlers
Sojourner API uses a database-backed job queue. All job state lives in the same database as the rest of the application. This was a deliberate early-stage decision: it eliminated the need for a separate Redis instance (cost and maintenance), removed the learning curve of a dedicated queue library, and kept the infrastructure footprint small while Sojourner API was being built. The tradeoff is that high-throughput workloads would eventually warrant moving to a proper broker, but for current volumes it is the right fit.
The queue is built using AdonisJS with Lucid ORM and PostgreSQL. The scheduler is powered by adonisjs-scheduler.
There are two queues: emails for transactional emails (auth and subscription), and webhooks for processing incoming payment events from DodoPayments.
The API service and the scheduler service are deployed as two separate Dokploy services, each built from its own Dockerfile. They share the same PostgreSQL database. The scheduler never handles HTTP traffic — it only reads from and writes to the jobs and webhook_events tables.
The central queue table. Both the emails and webhooks queues are rows in this table, distinguished by queue_name.
A dedicated audit table for every verified DodoPayments webhook delivery. A webhook_events row is always created before its corresponding jobs row, inside the same transaction, so there is never a job without an event record.
┌─────────┐
│ pending │ ◄─── job created (or recovered from stuck)
└────┬────┘
│ worker picks job (FOR UPDATE SKIP LOCKED)
▼
┌────────────┐
│ processing │
└─────┬──────┘
┌─────────┴──────────┐
│ success │ failure
▼ ▼
┌───────────┐ ┌────────┐
│ completed │ │ failed │
└───────────┘ └───┬────┘
│ attempts < MAX (3)
▼
┌─────────┐
│ pending │ (scheduled_for = now + delay)
└─────────┘
Status transitions for webhook jobs are written within the same database transaction as the business logic, so a crash mid-processing will not leave a job silently dropped — see Stuck Job Recovery below.
Location: commands/process_jobs.ts
Schedule: every 5 seconds, withoutOverlapping()
Handles all outbound emails. On each invocation it:
- Queries the
jobstable for a single dueemailsjob (pendingor retryablefailed), ordered bypriority → scheduled_for → created_at. - Acquires a row-level lock with
FOR UPDATE SKIP LOCKEDso concurrent invocations never double-process the same job. - Sets
status = 'processing'and dispatches based onemailType:email_verification— resolvesemailVerificationServicefrom the IoC container and sends the verification email using the plain token inmetadata.password_reset— resolvespasswordResetServiceand sends the reset email.subscription_confirmation— resolvessubscriptionEmailServiceand sends the subscription receipt.
- On success: sets
status = 'completed', clearslast_error. - On failure: increments
attempts, setsstatus = 'failed', writeslast_error, and setsscheduled_forfor the next retry window (see Retry Logic).
Location: commands/process_webhooks.ts
Schedule: every 5 seconds, withoutOverlapping()
Handles DodoPayments webhook events. On each invocation it:
- Calls
recoverStuckWebhooks()first (see below). - Opens a database transaction (
db.transaction()). - Queries for a single due
webhooksjob withFOR UPDATE SKIP LOCKED. - Sets
job.status = 'processing'within the transaction. - Loads the linked
WebhookEventand delegates toWebhookProcessorService.processWebhookEvent(). - On success:
- Marks
webhook_event.status = 'completed', setsprocessedAt. - Marks
job.status = 'completed'. - If
processWebhookEvent()returned aUser, creates a newemailsqueue job forsubscription_confirmation— within the same transaction, so the confirmation email job is only enqueued if the subscription state update committed successfully.
- Marks
- On failure: increments attempts on both the
jobandwebhook_eventrows, writeslast_error, schedules retry. - Commits or rolls back the transaction.
Because the entire processing cycle — subscription state mutation, event status update, job status update, and confirmation email enqueue — happens inside one transaction, a failure at any point rolls everything back cleanly.
Location: app/services/webhook_processor_service.ts
Receives a WebhookEvent and a transaction client and routes to the appropriate handler based on event_type. All handlers run inside the caller's transaction. |
Each handler delegates to either IndividualSubscriptionService or GroupSubscriptionService depending on payload.metadata.subscription_type.
Orphan healing: handleSubscriptionActive checks for an existing subscription record with a null dodoSubscriptionId. If none is found it creates one before activating, which handles cases where a checkout session completed but an earlier webhook delivery failed.
Both workers share the same retry configuration, driven by the WEBHOOK_MAX_ATTEMPTS environment variable (defaults to 3):
MAX_ATTEMPTS = env.get('WEBHOOK_MAX_ATTEMPTS', 3)
RETRY_DELAYS = [0, 60, 300] // seconds: immediate, 1 min, 5 min
On each failure the worker calculates nextAttemptCount = job.attempts + 1. If nextAttemptCount < MAX_ATTEMPTS, the job is rescheduled:
Jobs stay in the table permanently after exhausting retries. They are not deleted automatically until the quarterly cleanup removes completed and failed rows older than 3 months.
process:webhooks calls recoverStuckWebhooks() at the start of every invocation. It queries for webhook_events rows with status = 'processing' and updated_at older than 5 minutes — indicating the worker that claimed them crashed or was killed before completing. These rows are reset to status = 'pending' with last_error = 'Recovered from stuck processing state', making them eligible for pickup on the next cycle.
process:jobs does not have an equivalent recovery step. Because it does not wrap the email dispatch in a transaction, a crash mid-send is less likely to leave a job permanently stuck in 'processing' — but it is worth adding the same recovery step if email delivery volume grows.
DodoPayments
│
│ POST /webhooks
│ (webhook-id, webhook-signature, webhook-timestamp headers)
▼
WebhooksController.handle()
│
├── verify signature via dodoPaymentService.client.webhooks.unwrap()
│ └── on failure → 400 WebhookVerificationException
│
└── db.transaction()
├── INSERT webhook_events (status: pending)
├── INSERT jobs [queue: webhooks] (status: pending)
└── commit
│
▼
200 { received: true } ← DodoPayments considers delivery complete
... (up to 5 seconds later) ...
process:webhooks (scheduler)
│
├── recoverStuckWebhooks()
├── db.transaction()
│ ├── SELECT job FOR UPDATE SKIP LOCKED
│ ├── job.status = 'processing'
│ ├── WebhookProcessorService.processWebhookEvent()
│ │ └── mutates subscription state
│ ├── webhook_event.status = 'completed'
│ ├── job.status = 'completed'
│ ├── INSERT jobs [queue: emails, type: subscription_confirmation]
│ └── commit
│
... (up to 5 seconds later) ...
process:jobs (scheduler)
│
├── SELECT email job FOR UPDATE SKIP LOCKED
├── subscriptionEmailService.sendSubscriptionConfirmation()
└── job.status = 'completed'
This section outlines the architecture and implementation details for the real-time chat functionality within Sojourner API. It integrates Socket.io's Server-API directly into AdonisJS's underlying Node.js server to ensure seamless session authentication and routing.
A significant architectural insight for this implementation came from the nedois/adonis-chat-demo repository. It provided the critical pattern for correctly tapping into AdonisJS's underlying Node.js server and creating a mock HTTP context to parse session cookies over WebSockets.
Integrating Socket.io with AdonisJS presents a two-fold architectural challenge. Because Adonis completely abstracts the underlying Node.js HTTP server and relies on a strict middleware pipeline, a naive Socket.io integration typically fails in two distinct phases: first at the protocol upgrade level, and second when attempting to access the HTTP context for authentication.
Before a WebSocket connection can be established, the client must send an HTTP request with an Upgrade: websocket header.
- The Error: The client browser throws
WebSocket is closed before the connection is established, and the network tab shows the request permanently stuck in the "Switching Protocols" status. - The Cause: By default, AdonisJS's HTTP router handles all incoming traffic. If you attempt to instantiate Socket.io by passing the Adonis server object directly (instead of extracting the raw Node.js server), the Adonis router intercepts the upgrade request, fails to process the WebSocket protocol, and drops the connection.
Once the server is correctly bound using adonisServer.getNodeServer() and the socket upgrade succeeds, the connection inherently bypasses the standard Adonis HTTP middleware stack.
- The Error:
E_UNAUTHORIZED_ACCESS: Invalid or expired user sessionatSessionGuard.getUserOrFail, and the socket is disconnected. - The Cause: Because the standard session middleware was bypassed during the WebSocket upgrade, the incoming socket request never read or decrypted the session cookie. When the Auth module attempts to resolve the user, it finds an empty session and panics.
To resolve both phases, the Sojourner API explicitly binds Socket.io to the raw Node server (fixing the upgrade), and implements custom Socket middleware, see the /app/middleware/socket/socket_http_context_middleware.ts directory, to rebuild the HTTP context, decrypt the session, and await the authentication guard (fixing the auth).
The integration is broken down across specific services, providers, and custom middleware to strictly separate concerns and maintain the service patterns.
Location: app/services/socket.ts
It extracts the raw Node.js server instance from the Adonis application container and attaches Socket.io directly to it. This allows HTTP and WebSocket traffic to safely share the same port.
Location: providers/socket_provider.ts
This Adonis Service Provider ensures the socket server boots securely during the application's ready lifecycle phase. For more information on AdonisJS's lifecycles see documentation at AdonisJS Application Lifecycle It also handles dynamic imports for the WebSocket handlers to ensure all Adonis services are fully booted before listeners are attached. Finally, it registers a shutdown hook to close the socket server gracefully.
Location: app/middleware/socket/socket_http_context_middleware.ts
This custom Socket.io middleware intercepts the initial handshake. It takes the raw Node socket.request, creates a mock ServerResponse, and forces the Adonis server instance to build a complete HttpContext.
Once the context is created, it resolves the auth.manager from the IoC container, creates an authenticator, and attaches the fully hydrated context back onto the socket object (socket.context) thereby allowing the AdonisJS session authentication to function over the WebSocket protocol.
Location: app/services/websocket_service.ts
Once the context is established by the middleware, this service manages real-time state and event listeners.
- State Management: Uses in-memory maps (
userConnections,typingUsers,typingTimeouts) to track active socket IDs against authenticated user IDs. - Connection Logic: The
connectionhandler safely extracts the authenticated user from the injectedsocket.context.auth. If the user is invalid, the socket is immediately disconnected. - Event Listeners: Registers all core chat events —
join_room,send_message,typing_start,typing_stop,disconnect. Also exports utility functions such asdisconnectUserFromGroupto allow standard HTTP controllers to force-kick users from active socket rooms when access is revoked.
Location: app/services/chat_service.ts
Database interactions are decoupled from the WebSocket layer to keep the real-time server performant. The websocket_service.ts resolves ChatService via the Adonis IoC container to handle all database work.
The API is deployed like you would a Node.js application on an Ubuntu server. In production, the API and queue run as two different services that share the same PostgreSQL database.
Built and run with the standard application Dockerfile. Handles all HTTP traffic.
Dockerfile: Dockerfile.scheduler
The container runs scheduler:run as a long-lived process. adonisjs-scheduler fires commands on their configured intervals from within that process without a system cron or external job runner.
Configured schedules (start/scheduler.ts):
All scheduled commands use .withoutOverlapping(), which prevents a second invocation from starting if the previous one is still running.
The scheduler service requires the same database connection environment variables as the API service. Ccheck the .env.example file for a snippet into the required variables.