Developer docs
Munja runs on the page customers see after they pay or book. It never touches checkout.
Sign in to see samples with your own test keys.
Quick start
Add a container where offers should appear, load the script with your publishable key, and queue a show call. The queue works before the script has loaded.
The customer's name and email stay in the browser until they say yes to an offer. If there is nothing worth showing, the container stays empty and the page looks as it did.
Your keys are in Install in your dashboard. Use pk_test_ while building.
<div id="munja-moment"></div>
<script async src="https://getmunja.com/v1.js" data-key="pk_test_..."></script>
<script>
window.Munja = window.Munja || [];
Munja.push(["show", {
order: { id: "ORDER-1042", signature: "SIGNED_ON_YOUR_SERVER" },
customer: { email: "dana@example.com", name: "Dana" },
context: { zip: "60614", newCustomer: true, orderTotal: 129.0 }
}]);
</script>Signed orders
Sign each order on your server so fake traffic cannot earn from your account. The signature is HMAC-SHA256, in hex, of order_id + "." + email, with the email trimmed and lowercased, using your signing secret (mss_test_…).
A wrong signature is always rejected. A missing one is accepted unless you turn on require signed orders in your settings. Never put the signing secret in a browser.
import { createHmac } from "node:crypto";
// orderId and email are the same values you pass to Munja.show
const signature = createHmac("sha256", process.env.MUNJA_SIGNING_SECRET)
.update(`${orderId}.${email.trim().toLowerCase()}`)
.digest("hex");Order context
Context decides which offers are relevant. Every field is optional. Send what you know.
zip(or any key ending inZip, such astoZip): local partners only appear in the areas they serve.newCustomer:truefor a first order. Offers can target new or returning customers.orderTotalin dollars, ororderTotalCentsin cents: offers can set a minimum or maximum order value.products(alsoitems,skus,lineItems): names or SKUs, or objects withname,titleorsku.- Dates end in
Dateand useYYYY-MM-DD.
| Industry | Fields |
|---|---|
| Moving | moveDate (YYYY-MM-DD), homeSize (Studio | 1 bedroom | 2 bedrooms | 3 bedrooms | 4+ bedrooms), fromZip (5 digits), toZip (5 digits), distance (Local | Long distance) |
| Pet supplies | petName, species (Dog | Cat), breed, age, firstOrder (Yes | No), zip (5 digits) |
| Furniture | item (Sofa | Bed frame | Dining table | Desk), deliveryDays, replacing (Replacing an old one | Furnishing a new space), zip (5 digits) |
| Electronics | product (TV | Laptop | Phone | Smart home kit | Gaming console), price, zip (5 digits) |
| Landscaping | service (Weekly mowing | Spring cleanup | Fall cleanup | Tree trimming | Patio install), lot (Small | Medium | Large), irrigation (Yes | No), zip (5 digits) |
| Heating and cooling | service (Furnace install | AC repair | Tune-up | Duct cleaning), systemAge, homeAge, zip (5 digits) |
| Auto repair | service (Oil change | Brake repair | Tire replacement | Major repair), mileage, vehicleAge, zip (5 digits) |
| Fashion and beauty | item (Clothing | Shoes | Accessories | Skincare | Makeup | Fragrance), price, zip (5 digits) |
| Home and kitchen | item (Mattress | Appliance | Cookware | Bedding | Decor | Lighting), price, zip (5 digits) |
| Baby and kids | item (Stroller | Car seat | Crib | Nursery furniture | Clothing | Toys), price, zip (5 digits) |
| Sports and outdoors | item (Bike | Camping gear | Fitness equipment | Golf | Ski and snowboard | Running), price, zip (5 digits) |
| Food and drink | item (Coffee | Meal kit | Wine | Snacks | Specialty food), price, zip (5 digits) |
| Other e-commerce | item, price, zip (5 digits) |
| Plumbing | service (Water heater install | Drain cleaning | Leak repair | Repipe | Sewer line), when (YYYY-MM-DD), zip (5 digits) |
| Roofing and exteriors | service (Roof replacement | Roof repair | Gutters | Siding | Windows), when (YYYY-MM-DD), zip (5 digits) |
| Remodeling | service (Kitchen remodel | Bathroom remodel | Basement finish | Flooring | Painting), when (YYYY-MM-DD), zip (5 digits) |
| Junk removal and hauling | service (Junk removal | Furniture removal | Appliance removal | Full cleanout | Construction debris), when (YYYY-MM-DD), zip (5 digits) |
| Cleaning | service (Recurring cleaning | Deep clean | Move-out clean | Carpet cleaning | Window cleaning), when (YYYY-MM-DD), zip (5 digits) |
| Pest control | service (General pest | Termites | Rodents | Mosquitoes | Bed bugs), zip (5 digits) |
| Solar and electrical | service (Solar install | Panel upgrade | EV charger install | Generator | Rewiring), when (YYYY-MM-DD), zip (5 digits) |
| Other home services | service, price, zip (5 digits) |
| Car sales | vehicle (Used car | New car | Lease | Truck | EV), when (YYYY-MM-DD), price, zip (5 digits) |
| Collision and glass | service (Collision repair | Dent repair | Windshield replacement | Paint), zip (5 digits) |
| Car wash and detailing | service (Full detail | Ceramic coating | Wash membership | Window tint), zip (5 digits) |
| Other auto services | service, price, zip (5 digits) |
| Hotels and rentals | stay (Hotel stay | Vacation rental | Resort | Cabin), when (YYYY-MM-DD), price, zip (5 digits) |
| Tours and activities | item (City tour | Boat tour | Day trip | Theme park | Museum pass), when (YYYY-MM-DD), zip (5 digits) |
| Other travel and hospitality | service, price, zip (5 digits) |
| Gyms and studios | item (Gym membership | Class pack | Personal training | Yoga | Pilates), zip (5 digits) |
| Salons and spas | service (Haircut | Color | Massage | Facial | Nails), when (YYYY-MM-DD), zip (5 digits) |
| Other health and wellness | service, price, zip (5 digits) |
| Events and tickets | item (Concert | Sports game | Theater | Festival | Conference), when (YYYY-MM-DD), zip (5 digits) |
| Weddings and parties | service (Venue | Photography | Catering | Florist | Rentals), when (YYYY-MM-DD), zip (5 digits) |
| Other events and weddings | service, price, zip (5 digits) |
| Property management | unit (Studio | 1 bedroom | 2 bedrooms | 3+ bedrooms | Single-family home), when (YYYY-MM-DD), zip (5 digits) |
| Estate sales and downsizing | service (Estate sale | Downsizing | Online auction | Cleanout | Appraisal), when (YYYY-MM-DD), zip (5 digits) |
| Home sales | deal (Home purchase | Home sale), when (YYYY-MM-DD), zip (5 digits) |
| Self storage | item (Small unit | Medium unit | Large unit | Climate controlled), when (YYYY-MM-DD), zip (5 digits) |
| Other real estate | service, price, zip (5 digits) |
| Tax and accounting | service (Tax return | Bookkeeping | Business formation | Payroll setup), zip (5 digits) |
| Legal services | service (Estate planning | Business contracts | Real estate closing | Trademark), zip (5 digits) |
| Other professional services | service, price, zip (5 digits) |
| Classes and courses | item (Online course | Tutoring | Coding bootcamp | Music lessons | Driving school), when (YYYY-MM-DD), price, zip (5 digits) |
| Other education | item, price, zip (5 digits) |
context: {
zip: "60614", // decides which local partners can appear
newCustomer: true, // first order with you (true, false, or leave out)
orderTotal: 129.00, // dollars; or orderTotalCents: 12900
products: [ // names or SKUs, or { name, sku } objects
{ name: "2 bedroom move", sku: "MOVE-2BR" }
],
moveDate: "2026-11-14" // industry fields: see the table
}Exit popup
Add data-exit="on" to the script on every page to show offers when a visitor is about to leave. Turn it on and set how often it may appear in your dashboard.
It shows at most once a visit and no more often than the frequency you set. It stays out while a known popup tool (or a selector you add) is open, and for the rest of the visit after you call sitePopupShown. Leave the attribute off your checkout and cart pages.
<script async src="https://getmunja.com/v1.js" data-key="pk_test_..." data-exit="on"></script>
<!-- If your site opens its own popup, tell Munja so it stays out for the visit -->
<script>
window.Munja = window.Munja || [];
Munja.push(["sitePopupShown"]);
</script>Shopify, WordPress and others
The Shopify app and the WordPress plugin are not published yet. Until they are, paste the snippet into the page customers see after paying: on Shopify, the order status page's additional scripts; on WooCommerce, the order-received page using a code snippets plugin.
For Google Tag Manager, use a Custom HTML tag that fires on your thank-you page and maps data layer values into order and context.
If you render the page on your server, you can put the order on the script tag instead of calling show.
<!-- Fill these from your own order record when the page renders -->
<div id="munja-moment"></div>
<script async src="https://getmunja.com/v1.js"
data-key="pk_test_..."
data-order="ORDER_ID"
data-signature="SIGNATURE_FROM_YOUR_SERVER"
data-email="CUSTOMER_EMAIL"
data-name="CUSTOMER_FIRST_NAME"></script>Get offers
The embed calls this endpoint. Call it directly for receipt emails, customer portals or native apps. JSON in and out, snake_case keys, money as integer cents. It takes your publishable key in the body and allows 600 requests a minute per IP.
surface is one of thank_you, exit, email or portal. An empty data list means show nothing; empty_reason says why.
const res = await fetch("https://getmunja.com/v1/offers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "pk_test_...",
order: { id: "ORDER-1042", signature },
customer: { email: "dana@example.com", name: "Dana" },
context: { zip: "60614" },
}),
});
const { data } = await res.json(); // [] means show nothing{
"object": "offer_list",
"decision": "dec_...",
"livemode": false,
"data": [
{
"impression": "imp_token_...",
"position": 1,
"partner": "Partner name",
"headline": "...",
"body": "...",
"cta": "Yes, contact me",
"consent": "Text shown next to the button"
}
],
"moment": { "merchant": "...", "title": "...", "layout": "stack" }
}Accept an offer
When the customer says yes, send the impression token with their email and name. The partner is told about the lead and the customer sees the confirmation message. There is also a matching /dismiss endpoint.
await fetch(`https://getmunja.com/v1/offers/${encodeURIComponent(offer.impression)}/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "dana@example.com", name: "Dana" }),
});Send leads from your server
If you show offers in your own interface, send each acceptance with your secret key (sk_test_…, server only) and the exact disclosure text the customer saw. Every POST needs an Idempotency-Key header, so retries never create a second lead.
A lead from the same email for the same partner within the duplicate window comes back with status: "duplicate" and is not charged.
const res = await fetch("https://getmunja.com/api/v1/leads", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MUNJA_SECRET_KEY}`,
"Idempotency-Key": `${orderId}-${offerId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ offer_id: offerId, email, disclosure_text: shownText, contact: { name } }),
});
// 201 { "id": "lead_...", "object": "lead", "status": "charged" | "duplicate", ... }Partner: conversions and refunds
Partners report sales with the mj_click_id from the lead (or the lead ID), so merchants can see what their customers bought. Value is in cents.
Request a refund for a bad lead with a reason: duplicate, wrong_contact (invalid contact details), out_of_area, test, fraud or other, within 14 days of the lead.
await fetch("https://getmunja.com/api/v1/conversions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MUNJA_SECRET_KEY}`, "Idempotency-Key": externalId, "Content-Type": "application/json" },
body: JSON.stringify({ mj_click_id: clickId, type: "purchase", value: 4900, external_id: externalId }),
});curl https://getmunja.com/api/v1/refund_requests \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: refund-lead_..." \
-H "Content-Type: application/json" \
-d '{"lead":"lead_...","reason":"wrong_contact","note":"Number disconnected"}'Lead webhooks
Add an endpoint in your dashboard to receive lead.created and other events. Each delivery is signed with your endpoint secret in the X-Munja-Signature header: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">. Reject anything older than 5 minutes.
Answer with any 2xx. Failed deliveries retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, and can be replayed from the delivery log. An endpoint that keeps failing for 3 days is turned off. More in webhook signatures.
{
"id": "evt_...",
"object": "event",
"type": "lead.created",
"livemode": false,
"created": 1793548800,
"data": {
"object": {
"object": "lead",
"id": "lead_...",
"mj_click_id": "mjc_...",
"offer": "ofr_...",
"merchant": "acct_...",
"price_cents": 3800,
"created": 1793548800,
"customer": { "email": "dana@example.com", "name": "Dana" }
}
}
}import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the exact request body string, before JSON parsing
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Errors
Errors return { error: { type, code, message, param, doc_url } } with a matching HTTP status. See the API reference for every code.
HTTP/1.1 401
{
"error": {
"type": "authentication_error",
"code": "invalid_signature",
"message": "The order signature does not match. ...",
"param": "signature",
"doc_url": "/docs/api#invalid_signature"
}
}Test mode
Use pk_test_ and sk_test_ keys while building. Offers show a small test notice, acceptances create test leads, and no one is charged or paid.