This guide walks through the key flows a frontend or mobile client needs to implement. All endpoints are prefixed with https://dev-api.sendeet.online/api/v1.
For interactive exploration use Swagger UI. For the full schema import openapi.json into Postman.
POST /auth/sign-up
Body: { name, email, password, referralCode? }
business — no role selection needed.{ accessToken, refreshToken, user }.referralCode if the user was referred by an affiliate.POST /auth/send-code
Body: { email } ← resend OTP
POST /auth/verify-email ← requires Authorization header
Body: { code }
verify-email marks the account as active. Until verified, protected routes that require requireVerified middleware will return 403.POST /auth/sign-in
Body: { email, password }
Returns: { accessToken, refreshToken, user }
POST /auth/refresh-token
Body: { refreshToken }
Returns: { accessToken }
POST /auth/forgot-password
Body: { email } ← sends OTP to email
POST /auth/verify-code
Body: { code } ← validate OTP before showing new-password screen
POST /auth/reset-password
Body: { email, code, password } ← set new password
GET /users/profile ← get current account
PATCH /users/profile
Body: { name?, avatarUrl? } ← only these two fields are accepted
PATCH /users/email
Body: { newEmail, code } ← OTP must first be sent to newEmail via /auth/send-code
DELETE /users/account ← soft-delete (recoverable by logging in again)
After sign-up, the business profile is empty. Guide the user through these steps before they can send campaigns.
PATCH /business
Body: {
companyName, companyLogo, address, website,
businessRegistrationNumber, countryCode,
industry, noOfEmployees, noOfSubscribers
}
countryCode is an ISO code (e.g. "NG"). Get the full list from GET /exchange-rates/countries.
POST /business/verification
Notifies all admin accounts. No action needed from the business until approved.
POST /business/provider
Body: {
provider: "gupShup",
wabaNumber,
gupshupHSMID,
gupshupHSMPassword,
gupshupDashboardLogin?,
gupshupDashboardPassword?,
gupshupTwowayLogin?,
gupshupTwowayPassword?
}
Passwords are encrypted at rest. The response returns "true" or "false" for each password field — never the actual value.
GET /business
Returns: { ...profile, wallet, userPlan }
GET /contacts?page=1&pageSize=25&search=Ada&optIn=true
POST /contacts
Body: { firstName, lastName, phoneNumber, tags?, optIn? }
Parse the CSV or Excel file in the browser with PapaParse or SheetJS, then send the rows:
POST /contacts/import
Body: {
contacts: [
{ firstName, lastName, phoneNumber, tags?, optIn? },
...
]
}
Returns: { imported, duplicatesSkipped, failed: [{ row, reason }] }
Duplicates (same phone number for this business) are silently skipped. Each row that fails validation is reported individually — the rest still import.
// 1. Get a presigned upload URL
POST /upload/presigned-url
Body: { fileName: "contacts.csv", contentType: "text/csv", folder: "imports" }
Returns: { uploadUrl, fileUrl, key }
// 2. PUT the file bytes directly to uploadUrl (client-side, no Authorization header needed)
// 3. Hand the fileUrl to the server
POST /contacts/import-file
Body: { fileUrl }
Returns: { imported, duplicatesSkipped, failed: [{ row, reason }] }
GET /contacts/export?format=csv ← downloads contacts.csv
GET /contacts/export?format=xlsx ← downloads contacts.xlsx
GET /contacts/export?optIn=true ← only opted-in contacts
GET /contacts/export?tag=vip ← filter by tag
// 1. Get presigned URL
POST /upload/presigned-url
Body: { fileName: "brochure.pdf", contentType: "application/pdf", folder: "campaigns" }
Returns: { uploadUrl, fileUrl, key }
// 2. PUT file bytes to uploadUrl (no Authorization header)
// 3. Confirm the upload
POST /upload/confirm
Body: { url: fileUrl, fileName, size, type }
Returns: UploadFile record
Use the fileUrl from step 1 as uploadFileUrl when creating a campaign.
GET /payments/wallet ← credit balance
GET /plans/my-plan ← active plan details
POST /campaigns
Body: {
name,
message,
messageType: "marketing" | "utility" | "authentication",
sendToAllContact: true, ← or false + selectedContactId[]
isScheduled: false, ← immediate send
paymentMethod: "credit" | "plan",
uploadFileUrl?, ← from /upload/confirm
header?,
actionButtonTitle?,
actionButtonUrl?
}
Omit paymentMethod to save as draft (no billing, no send).
POST /campaigns
Body: {
...same fields,
isScheduled: true,
scheduledAt: "2025-09-15T14:00:00.000Z", ← UTC ISO 8601
timeZone: "Africa/Lagos", ← stored for display only
paymentMethod: "credit"
}
Convert the user's local time to UTC before sending scheduledAt. The timeZone field is just for showing the user their original selection — all delivery logic uses UTC.
POST /campaigns/:id/publish
Body: { paymentMethod: "credit" | "plan" }
GET /campaigns/stats
Returns: { total, sent, pending, scheduled, draft }
GET /campaigns/qr-code
Returns: { link, imageBase64 }
imageBase64 is a PNG data URL — render it directly in an <img src="..."> tag.
GET /payments/credit-pricing?totalCredit=100
Returns: { totalCredit, priceUSD, priceNGN, currency, rates }
POST /payments/credit-link
Body: { totalCredit: 100 }
Returns: { authorization_url, reference, ... }
Redirect the user to authorization_url. After payment, Paystack fires the webhook automatically. You can also manually verify:
POST /payments/verify
Body: { reference }
GET /plans ← list active plans (prices in NGN)
POST /payments/plan-link
Body: { planId }
Returns: { authorization_url, ... }
GET /payments/wallet
GET /payments/transactions?page=1&pageSize=25
GET /notifications?page=1&pageSize=25 ← includes unread count
POST /notifications/:id/read
POST /notifications/read-all
GET /notifications/settings ← key/value object
PATCH /notifications/settings
Body: { campaignCompletionPush: "false", lowCreditsEmail: "true" }
Connect to the server and join the appropriate room after sign-in:
import { io } from "socket.io-client";
const socket = io(BASE_URL);
// Business users
socket.emit("joinBusinessRoom", businessId);
// Admin users
socket.emit("joinAdminRoom", accountId);
// On reconnect
socket.emit("rejoinRooms", { businessId });
// Listen for notifications
socket.on("notification", (notification) => {
// { id, type, title, message, isRead, createdAt, ... }
showToast(notification.title);
refreshNotificationList();
});
// Confirm room joined
socket.on("joinedRoom", ({ roomType, roomId }) => {
console.log(`Joined ${roomType} room ${roomId}`);
});
The notification event fires on: campaign sent, contact opt-in/out, payment success, business verified/rejected.
POST /tickets
Body: { issueType, question, uploadFileUrl? }
GET /tickets
GET /tickets/:id
POST /tickets/:id/comments
Body: { content }
issueType options: organizationAccount, userSubscription, paymentAndCredit, bugReports, featureRequests, campaignDelivery.
GET /affiliate/referrals
GET /affiliate/earnings ← { balance, totalEarned, totalWithdrawn }
GET /affiliate/transactions
GET /affiliate/bank-accounts/verify?accountNumber=&bankCode= ← resolve name
POST /affiliate/bank-accounts
Body: { bankName, accountNumber, accountName, code }
POST /affiliate/withdraw
Body: { bankAccountId, amount } ← amount in NGN
All errors follow this shape:
{
"apiObject": "Campaign",
"code": 400,
"status": "failure",
"message": "Insufficient credits",
"errorMessage": {}
}
Common status codes:
| Code | Meaning |
|---|---|
| 400 | Bad request — missing field or business rule violation |
| 401 | Unauthenticated — missing or invalid token |
| 403 | Forbidden — wrong role or unverified account |
| 404 | Resource not found |
| 500 | Internal server error |