Building a Payment System in a Country Without Stripe
BirJob is Azerbaijan's job aggregator. I built it solo. It scrapes 91+ job sources, puts them all in one place, and lets people find work without opening 40 browser tabs. At some point, the project needed to make money. I wanted to sell sponsored job postings and HR subscriptions. Simple enough, right? Just drop in Stripe, add a checkout button, done.
Except Stripe does not operate in Azerbaijan. Neither does Paddle. Neither does Lemon Squeezy. Neither does Braintree. Neither does any of the well-documented, developer-friendly payment processors that the rest of the internet writes tutorials about.
This is the story of building a production payment system using a local Azerbaijani gateway called Epoint, and everything I learned about payment integration when your only option is a provider with a 12-page PDF for documentation.
1. The Landscape: Why Nothing "Just Works"
If you are building a SaaS or marketplace product in the US, EU, or most of Southeast Asia, you have options. Stripe alone supports 46+ countries. You npm-install their SDK, copy some example code, and you have a working checkout in an afternoon.
Azerbaijan is not on that list. It is not on Paddle's list either. Or Razorpay's. Or Paystack's. The country has its own banking infrastructure, its own card networks, and its own set of payment processors that exist to serve local merchants. These processors are built by and for banks, not for developers.
The options I found:
- Epoint.az -- run by Azericard (the national card processing company). REST-ish API. Accepts local Visa/Mastercard.
- GoldenPay -- similar, but less documentation available online.
- Payriff -- by Kapital Bank. Newer, but tied to a specific bank.
- Direct bank integrations -- possible but requires a corporate contract with each bank individually.
I went with Epoint for one reason: I found a working code example online. That is not a joke. When you are choosing between payment providers in a market like this, "someone on the internet managed to integrate it" is a genuine differentiator.
2. Epoint's API: What You Get
The Epoint API is conceptually simple. There are really only two operations:
- Create a payment: You POST a JSON payload containing the amount, order ID, and redirect URLs. Epoint gives you back a URL. You redirect the user there. They enter their card details on Epoint's hosted page.
- Receive a webhook: After the user pays (or fails to pay), Epoint POSTs a callback to your server with the result.
That is it. No client-side SDK. No embedded checkout. No Apple Pay or Google Pay wrappers. No subscription billing engine. No invoices. No refund API (you email them). You get a redirect and a webhook. Everything else is your problem.
Coming from reading Stripe's docs for years, this felt like going from a luxury car to a manual transmission truck. But honestly, for what BirJob needs, a redirect-based flow is fine. Most Azerbaijani users are used to being sent to a bank page to enter their card details. There is no UX penalty for this approach here.
3. The Signature Scheme
This is where Epoint's design gets interesting. Every request to Epoint -- and every webhook from Epoint -- is authenticated using a signature. The scheme works like this:
- Take your JSON payload and Base64-encode it.
- Concatenate:
PRIVATE_KEY + base64_payload + PRIVATE_KEY - SHA1-hash the result and Base64-encode the hash.
- Send both the Base64 payload and the signature.
Here is the actual code from BirJob's epoint.ts library:
export function encodeData(payload: object): string {
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
export function createSignature(data: string): string {
const hash = crypto.createHash('sha1');
hash.update(PRIVATE_KEY + data + PRIVATE_KEY);
return hash.digest('base64');
}
If you have worked with Stripe's webhook verification (HMAC-SHA256 of the raw body), this looks... different. Epoint uses SHA1, not SHA256. The private key is prepended AND appended to the data, like a sandwich. It is not HMAC -- it is a raw hash with the key baked into the input.
Is this as cryptographically robust as HMAC-SHA256? No. But it works. The private key never leaves your server. The signature cannot be forged without knowing the key. For a payment integration processing domestic card transactions, it is sufficient.
One thing I got right from the start: timing-safe comparison for webhook verification. Even with SHA1, you do not want to leak information about the expected signature through timing side-channels:
export function verifySignature(data: string, signature: string): boolean {
const expected = createSignature(data);
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
crypto.timingSafeEqual compares two buffers in constant time. A naive
=== string comparison would return false as soon as the first byte differs, which
theoretically lets an attacker figure out the signature byte by byte. Overkill for most
scenarios? Probably. But it is three extra lines and it is the right thing to do.
4. The Payment Flow: From Button Click to Activated Service
Let me walk through the entire flow for a sponsored job posting on BirJob. This is the core paid feature: an HR person fills out a job listing form, pays, and the listing goes live at the top of the homepage.
Step 1: The Checkout Form
The form lives at /hr/sponsor. The HR user fills in the job title, company name,
description, and picks a duration plan. BirJob offers three plans:
const PRICING: Record<number, number> = {
7: 30, // 7 days = 30 AZN (~$17.60)
14: 50, // 14 days = 50 AZN (~$29.40)
30: 80, // 30 days = 80 AZN (~$47.00)
};
When they click the submit button, the text changes from
"50 ₼ Ödə və Elanı Yayımla" (Pay 50 AZN and Publish the Job)
to "Yönləndirilir..." (Redirecting...). The form POSTs to
/api/payments/initiate.
Step 2: Create the Pending Record
The backend does two things atomically-ish. First, it creates the sponsored job in the database
with is_active: false and payment_status: 'pending':
const orderId = randomUUID();
const endsAt = new Date();
endsAt.setDate(endsAt.getDate() + durationDays);
const job = await prisma.sponsored_job.create({
data: {
title,
company,
apply_link: apply_link || null,
apply_type,
description: description || null,
requirements: requirements || null,
hr_user_id: session?.userId ?? null,
ends_at: endsAt,
is_active: false,
order_id: orderId,
payment_status: 'pending',
amount,
},
});
The order_id is a UUID. This becomes the link between BirJob's database and
Epoint's payment record. The order_id column has a @unique constraint
in the Prisma schema, so it also serves as the idempotency key.
Step 3: Call Epoint to Create the Payment
Next, we call initiatePayment which hits Epoint's
https://epoint.az/api/1/request endpoint:
const payload: EpointPayload = {
public_key: PUBLIC_KEY,
amount,
currency: 'AZN',
language: 'az',
order_id: orderId,
description,
success_redirect_url: `${BASE_URL}/payments/success?order_id=${orderId}`,
error_redirect_url: `${BASE_URL}/payments/error?order_id=${orderId}`,
};
const data = encodeData(payload);
const signature = createSignature(data);
const response = await fetch('https://epoint.az/api/1/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data, signature }),
});
Notice the payload structure. Everything goes through Base64 encoding. You do not send raw JSON
to Epoint. You send { data: "base64...", signature: "base64..." }. Epoint decodes
the data on their side, verifies the signature, and if everything checks out, returns a
redirect_url.
If Epoint's API is unreachable or returns an error, we clean up the pending job record immediately:
if (epointResponse.status !== 'success' || !epointResponse.redirect_url) {
await prisma.sponsored_job.delete({ where: { id: job.id } });
return NextResponse.json(
{ error: 'Ödəniş sistemi ilə əlaqə qurula bilmədi.' },
{ status: 502 }
);
}
This cleanup is important. Without it, you would accumulate orphaned pending records in your database every time Epoint has a hiccup.
Step 4: Redirect to Epoint's Hosted Payment Page
If everything went well, the API returns { redirect_url: "https://epoint.az/..." }
to the frontend. The frontend does a hard redirect:
window.location.href = json.redirect_url;
The user lands on Epoint's payment page, enters their card number, and Epoint handles 3D Secure verification with the issuing bank. This part is entirely out of my hands. The UX of Epoint's hosted page is whatever Epoint decided it should be. I have zero control over it.
Step 5: Webhook Callback
After payment completes (or fails), two things happen simultaneously:
- The user is redirected to either
success_redirect_urlorerror_redirect_url. - Epoint POSTs a webhook to BirJob's callback endpoint.
The webhook is the source of truth. The redirect URL is just for UX. I never trust the redirect to mean payment actually succeeded. Only the verified webhook activates the service.
Step 6: Verify and Activate
The webhook handler at /api/payments/result does the following:
export async function POST(request: NextRequest) {
const contentType = request.headers.get('content-type') ?? '';
let data: string | null = null;
let signature: string | null = null;
if (contentType.includes('application/x-www-form-urlencoded')) {
const form = await request.formData();
data = form.get('data') as string | null;
signature = form.get('signature') as string | null;
} else {
const body = await request.json().catch(() => null);
data = body?.data ?? null;
signature = body?.signature ?? null;
}
if (!data || !signature) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
if (!verifySignature(data, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
}
const payload = decodeData<EpointWebhookData>(data);
const { status, order_id, transaction } = payload;
// ...
}
A few things worth noting here. Epoint sometimes sends webhooks as
application/x-www-form-urlencoded and sometimes as application/json.
I have seen both in production. So the handler checks the Content-Type header and parses
accordingly. I did not find this in any documentation. I found it in my server logs after the
first webhook arrived in a format I did not expect.
After signature verification, we decode the Base64 payload to get the order ID and payment
status. If the status is "success", we flip the job to active:
if (status === 'success') {
await prisma.sponsored_job.update({
where: { order_id },
data: {
is_active: true,
payment_status: 'paid',
epoint_transaction: transaction || null,
starts_at: new Date(),
},
});
}
The job is now live. It shows up at the top of BirJob's homepage with a "Sponsorlu" badge.
5. Idempotent Webhook Handling
Payment webhooks can arrive more than once. Epoint might retry. Network issues might cause duplicates. Your server might respond with a 500 before the database write commits. You have to handle this.
BirJob's approach is straightforward: never downgrade a payment that is already confirmed.
// Idempotency: never downgrade a job that is already paid
if (job.payment_status === 'paid' && status !== 'success') {
return NextResponse.json({ success: true });
}
If a job's payment_status is already 'paid' and we get another
webhook that says anything other than 'success', we just return 200 and do nothing.
This prevents a scenario where a delayed "cancel" or "failed" webhook arrives after a
successful one and deactivates a job that was already paid for.
The order_id being a UUID with a unique constraint in the database is what makes
this work. Each payment attempt gets exactly one order ID. The findUnique lookup
will always find the same record, and the idempotency guard prevents double-processing.
6. The Data Model
BirJob has three payment-related Prisma models, each serving a different product:
Sponsored Jobs
model sponsored_job {
id Int @id @default(autoincrement())
title String @db.VarChar(500)
company String @db.VarChar(500)
apply_link String? @db.VarChar(1000)
apply_type String @default("external") @db.VarChar(20)
description String?
requirements String?
hr_user_id Int?
is_active Boolean @default(false)
starts_at DateTime @default(now())
ends_at DateTime
order_id String? @unique @db.VarChar(255)
payment_status String @default("pending") @db.VarChar(50)
amount Decimal? @db.Decimal(10, 2)
epoint_transaction String? @db.VarChar(255)
// ... relations
}
The payment metadata lives directly on the sponsored job record. I debated having a separate
payment table and linking it, but for sponsored jobs, the payment and the product
are a 1:1 relationship. Keeping them on the same row means one fewer JOIN and one fewer thing
to go wrong.
Notice amount is Decimal(10, 2), not a float. Never use floats for
money. Prisma returns this as a Decimal object, which means you need to call
Number(job.amount) when you want to do math with it. A small annoyance, but better
than floating-point rounding errors on financial data.
HR Subscriptions
model hr_subscription {
id Int @id @default(autoincrement())
user_id Int
plan String @db.VarChar(20)
amount Decimal @db.Decimal(10, 2)
order_id String @unique @db.VarChar(255)
payment_status String @default("pending") @db.VarChar(50)
epoint_transaction String? @db.VarChar(255)
starts_at DateTime?
ends_at DateTime?
created_at DateTime @default(now())
user user @relation(fields: [user_id], references: [id])
}
HR subscriptions unlock access to candidate CVs and the HR dashboard. Two plans: weekly (15 AZN) and monthly (40 AZN). Each purchase creates a new subscription record with a time window. There is no recurring billing -- Epoint does not support it. Every renewal is a manual repurchase.
HR Credit Transactions
model hr_credit_transaction {
id Int @id @default(autoincrement())
user_id Int
amount Int // positive = credits added, negative = credits spent
type String @db.VarChar(50) // purchase | cv_download
description String? @db.VarChar(500)
order_id String? @unique @db.VarChar(255)
payment_amount Decimal? @db.Decimal(10, 2) // AZN paid (purchases only)
payment_status String @default("pending") @db.VarChar(50)
epoint_transaction String? @db.VarChar(255)
created_at DateTime @default(now())
user user @relation(fields: [user_id], references: [id])
}
This is the most interesting model. It is a ledger. The amount field is an integer
that can be positive (credits purchased) or negative (credits spent on CV downloads). The
balance is not stored anywhere -- it is computed:
const result = await prisma.hr_credit_transaction.aggregate({
where: { user_id: session.userId, payment_status: 'paid' },
_sum: { amount: true },
});
const balance = result._sum.amount ?? 0;
A SUM query over all paid transactions for that user. Positive entries from purchases, negative entries from spending. The balance is always derived, never cached. This means you cannot have a balance that drifts out of sync with reality. The transaction log IS the balance.
Three credit packs are available:
const PACKS: Record<string, { credits: number; amount: number; label: string }> = {
pack_10: { credits: 10, amount: 12, label: '10 Kredit' },
pack_25: { credits: 25, amount: 25, label: '25 Kredit' },
pack_50: { credits: 50, amount: 45, label: '50 Kredit' },
};
Volume discounts. 10 credits at 1.2 AZN each, 50 credits at 0.9 AZN each. This encourages HR users to buy larger packs, which means fewer payment transactions for me to worry about.
7. Three Webhook Endpoints, Same Pattern
BirJob has three separate webhook endpoints because Epoint does not let you include metadata in the callback payload to distinguish payment types. Each product gets its own callback URL:
/api/payments/result-- Sponsored job payments/api/hr/credits/purchase/result-- Credit pack purchases/api/hr/subscription/result-- HR subscription purchases
All three follow the exact same pattern: parse the body (handling both form-encoded and JSON), verify the signature, decode the payload, look up the order, check idempotency, update the record. The code is nearly identical across all three. I could refactor this into a shared handler, but three small files that each do one obvious thing is easier to debug at 2 AM when a payment is not going through.
8. Edge Cases That Bit Me
Declined Cards
When a card is declined, Epoint redirects the user to the error_redirect_url and
sends a webhook with status: "failed". The webhook payload includes a
code field, but the codes are not well-documented. I just store them and map
everything that is not "success" to a failed state:
payment_status: status === 'success' ? 'paid' : status === 'cancel' ? 'cancelled' : 'failed',
There are three terminal states: paid, cancelled, failed. The user sees a generic error message on the error page. I cannot tell them "your card was declined because of insufficient funds" vs. "3D Secure failed" because Epoint does not reliably distinguish these in the callback.
Session Expiry on the Payment Page
If a user opens the Epoint payment page and then walks away for 20 minutes, the session expires. Epoint redirects them to the error URL. We get a webhook with a non-success status. The pending record gets marked as failed. If the user wants to try again, they fill out the form again and get a new order ID. Simple, but worth knowing about.
Epoint Sending Both Content Types
I already mentioned this, but it is worth repeating because it cost me a real debugging session.
Epoint's webhook POSTs sometimes arrive as application/json and sometimes as
application/x-www-form-urlencoded. I never figured out what determines which
format they choose. The handler now checks the Content-Type header and parses accordingly.
If you are integrating Epoint, handle both from day one.
Non-JSON Responses from Epoint's API
When Epoint's API has issues, it sometimes returns HTML error pages instead of JSON. The
initiatePayment function wraps the JSON parse in a try-catch:
try {
return JSON.parse(text) as EpointResponse;
} catch {
console.error('[epoint] non-JSON response:', text.slice(0, 500));
return { status: 'error', message: `Non-JSON response: ${text.slice(0, 200)}` };
}
Without this, a single Epoint downtime would crash the payment initiation endpoint with an unhandled JSON parse error.
Cleanup on Failure
If the Epoint API call fails (network error, non-success response, anything), we immediately delete the pending record we just created:
} catch (err) {
console.error('[epoint] fetch error:', err);
await prisma.sponsored_job.delete({ where: { id: job.id } });
return NextResponse.json({ error: '...' }, { status: 502 });
}
This is important for the credit purchase flow too. Without cleanup, an HR user who hits a network glitch would see phantom pending transactions in their history. Worse, if they tried again, the credits might eventually get double-applied if the original pending transaction somehow resolved later.
9. The Credit Balance System
I want to talk more about the credit system because I think it is the most elegant part of the payment architecture, and it was born out of a limitation.
Epoint does not support micro-transactions well. The minimum meaningful payment is probably around 1-2 AZN. But a single CV download should cost a fraction of that. The solution: credits.
HR users buy credit packs (10, 25, or 50 credits) in a single Epoint transaction. Each CV download costs one credit. The credit balance is a running sum of all paid transactions:
+25(type: "purchase") -- HR bought the 25-credit pack for 25 AZN-1(type: "cv_download") -- HR downloaded a candidate's CV-1(type: "cv_download") -- another download- Current balance:
23
The beauty of this approach is auditability. Every credit ever added or spent is a row in the database with a timestamp and description. If an HR user disputes a charge, I can pull up the entire history in one query. There is no "balance field" that could get corrupted or out of sync.
The downside: computing the balance requires aggregating all transactions. For an HR user
with hundreds of transactions, this query could theoretically get slow. In practice, BirJob
is not at that scale yet, and adding an index on user_id keeps it fast. If it
ever becomes a problem, I will add a materialized balance column and update it transactionally.
But premature optimization is not something I am interested in.
10. Testing Payments Without a Staging Environment
Epoint does not have a sandbox mode. Let me say that again: there is no test environment. No test card numbers. No "use 4242 4242 4242 4242." Every transaction during development was a real transaction with real money on my real card.
How I dealt with this:
- Low amounts: During testing, I set the price to the minimum amount Epoint would accept. Every test cost me real money, but at least it was small.
-
Logging everything: Every Epoint API call logs the HTTP status and first
500 characters of the response:
console.log(`[epoint] HTTP ${response.status} -- ${text.slice(0, 500)}`);This saved me multiple times when debugging why a payment failed. - Manual database cleanup: After each test, I manually deleted the test records or marked them as inactive. Not elegant, but effective.
- Webhook testing with ngrok: Since webhooks need a public URL, I used ngrok to tunnel Epoint callbacks to my local machine. This is probably the single most useful tool in the entire integration process.
Compare this with Stripe, where you can run through the entire payment flow in test mode without spending a cent, switch card numbers to simulate different decline scenarios, and use the Stripe CLI to forward webhooks locally. The developer experience gap is enormous.
11. What Epoint Gets Right and What It Does Not
What works well:
- Simplicity: The API is small. There are maybe five endpoints total. You can learn the entire thing in a day. There is no 300-page API reference to wade through.
- Reliability: In production, Epoint has been solid. Payments go through. Webhooks arrive. I have not had a single case of a successful payment not triggering a webhook.
- Local card support: It works with every Azerbaijani bank card I have tested. Visa, Mastercard, local debit cards. The 3D Secure flow is handled by them.
- Fast settlement: Funds appear in the merchant account quickly by local standards.
What could be better:
- No sandbox: This is the biggest pain point. Testing with real money is not acceptable for any serious development workflow. Stripe had test mode in 2011. It is 2026.
- No refund API: To refund a payment, you contact Epoint support. There is no programmatic refund endpoint. If you are building a marketplace with buyer protection, this is a dealbreaker.
- No recurring billing: Want to charge someone monthly? You have to bring them back to the payment page every month. There is no "save card and charge later" capability exposed to merchants.
- Inconsistent webhook Content-Type: I should not have to handle both JSON and form-encoded bodies from the same webhook endpoint. Pick one.
- SHA1 signatures: SHA1 has been considered weak since 2017. HMAC-SHA256 is the industry standard. This is not a critical vulnerability in practice, but it does not inspire confidence.
- Documentation: A PDF. Not a website. Not an interactive API reference. A PDF.
- No webhook retry configuration: I cannot configure retry policies, view failed webhook deliveries, or manually re-trigger a webhook from a dashboard.
- No client-side SDK: Stripe Elements lets you embed card inputs directly in your page. With Epoint, every payment is a full-page redirect. This is fine for BirJob's use case but would be a problem for a checkout experience that needs to feel seamless.
12. Checkout UX Decisions for an Azerbaijani Audience
Building a checkout flow for Azerbaijani users is different from building one for a US audience. Here are the decisions I made and why:
Currency display: always AZN with the manat symbol
Prices are displayed as 50 ₼ (50 manats), not "$29" or "50.00 AZN". The manat
symbol (₼) is what people expect to see. Using dollar amounts would feel foreign and
create trust issues.
Language: Azerbaijani for the UI, Azerbaijani for Epoint
The language: 'az' parameter in the Epoint payload ensures the hosted payment page
renders in Azerbaijani. The BirJob form labels, error messages, and button text are all in
Azerbaijani too. "Ödə və Elanı Yayımla" (Pay and Publish) is more actionable
than "Submit Payment" would be for this audience.
Per-day pricing breakdown
Each plan shows a per-day cost: ≈ 3.6 ₼ / gün (approximately 3.6 AZN per day).
This anchoring technique works well because Azerbaijani users are very price-conscious. Showing
that the 14-day plan works out to less per day than the 7-day plan drives conversions to the
middle tier.
"Most Popular" badge
The 14-day plan is marked as "Ən Populyar" (Most Popular) with an orange badge. Classic pricing page technique. It works because it reduces decision fatigue -- most users just pick the highlighted option.
No registration required for sponsored jobs
You do not need a BirJob account to post a sponsored job. The hr_user_id field
is nullable. If you are logged in as an HR user, the form auto-fills your company name and
email. If you are not logged in, you can still fill everything out and pay. This reduces
friction significantly. In Azerbaijan's market, many small businesses would abandon the
flow if forced to create an account first.
Trust signals
The bottom of the checkout form says: "Ödəniş Epoint.az ilə təhlükəsiz şəkildə həyata keçirilir" (Payment is securely processed by Epoint.az). Mentioning Epoint by name matters because they are a known entity in Azerbaijan's financial ecosystem. People trust the name, even if they have never heard of BirJob before.
13. Lessons Learned
Building a payment system with a local gateway in a country without Stripe taught me a few things that I would not have learned otherwise:
The payment integration is 20% of the work. The actual API calls are simple. What takes time is the state management: pending records, cleanup on failure, idempotent webhooks, handling every combination of success/failure/timeout/duplicate. That is 80% of the code and 90% of the debugging.
Build for the gateway you have, not the one you wish you had. I spent zero time trying to make Epoint behave like Stripe. Redirect-based flow? Fine. No subscription billing? I will build a credit system. No refund API? I will handle refunds manually and build that bridge when I have enough volume to justify it.
Log everything. Every Epoint API response. Every webhook payload. Every
status transition. When something goes wrong with payments, you need forensic-level detail
to figure out what happened. A console.log that prints the first 500 characters
of every Epoint response has saved me more times than any unit test.
Idempotency is not optional. Webhooks will arrive more than once. Your code must handle this gracefully. The rule is simple: if the payment is already confirmed, ignore everything else.
Do not over-engineer. Three separate webhook handlers with nearly identical code? Not DRY. But each one is 50 lines, does exactly one thing, and I can debug any of them independently. When it is 3 AM and a payment webhook is failing, I do not want to trace through an abstraction layer.
The credit system was the right call. It solved the micro-transaction problem, gave HR users flexibility, and created a natural purchasing pattern (buy credits in bulk, spend them over time). It also means fewer Epoint transactions, which means fewer things that can go wrong.
14. Where It Goes From Here
The payment system works. People pay, services activate, money arrives. But there is a lot I want to improve:
-
Automatic expiry handling: Sponsored jobs have an
ends_attimestamp but there is no cron job yet to deactivate expired listings. I want to add a scheduled function that flipsis_activeto false when the time is up, and sends the HR user an email offering to renew. - Receipt emails: After a successful payment, the user should get a confirmation email with the amount paid and what they got. Right now they just see the success page.
- Payment dashboard: An admin view showing all transactions, revenue over time, and failed payment rates.
- Saved card / tokenization: If Epoint ever exposes a tokenization API, returning users should be able to pay without re-entering their card details.
- Alternative gateways: Payriff and GoldenPay are worth evaluating as fallback options. If Epoint goes down for maintenance, having a second gateway would keep revenue flowing.
Final Thoughts
If you are a developer in Azerbaijan, Georgia, Uzbekistan, or any other post-Soviet country where Stripe does not operate -- you are not stuck. The local payment gateways work. They are not as polished as Stripe. The documentation is worse. The developer experience is worse. There is no sandbox. But they process real payments from real cards and real money lands in your real bank account.
The key is to keep your integration simple, handle every failure case, and not try to build a Stripe-shaped system on top of a gateway that was not designed to be Stripe. Accept the limitations. Build around them. Ship the thing.
BirJob's payment system is about 500 lines of TypeScript across six files. It handles three
products, uses one external dependency (Epoint), and has processed every payment correctly
since launch. It is not glamorous. It does not use webhooks-as-a-service or event-driven
microservices or any of that. It is just fetch, crypto,
prisma, and some careful state management.
Sometimes that is all you need.
This article is part of the BirJob engineering blog. BirJob is Azerbaijan's job aggregator, scraping 91+ sources to help people find work. If you are hiring in Azerbaijan, you can post a sponsored job at birjob.com/sponsor.
