Developers / Booking API
Booking API course: build online booking on your site
Last updated: October 5, 2026
Build your own online booking screens for dumpsters and junk removal on the Dumpster Controls engine: every call, every field, every error, with working code.
The Booking API lets a dumpster rental or junk removal company run fully custom booking screens on its own website while Dumpster Controls prices the order, runs every rule, screens the payment and creates the order on the dispatch board.
This module is the complete course: concepts, keys and domains, the read endpoints, quotes, the order with card payment, junk removal, webhooks, errors, security, working examples and a go-live checklist. It is written so that a developer, or an AI coding tool, can build a working integration in one sitting.
Everything here is included free on every plan, with no approval. Base URL: https://gcwyoiihrupbfqqlcurh.supabase.co/functions/v1/booking-api/v1. Spec: https://dumpstercontrols.io/openapi-booking.json. Reference pages: https://dumpstercontrols.io/developers/booking and https://dumpstercontrols.io/developers/booking/api.
This page is the whole course on one page, so a developer or an AI tool can read it in one pass. Each lesson is also a separate article in the manual at help/booking-api, which Tresha, the assistant inside the app, reads and answers from. Short reference: Booking API reference; widget: widget reference.
Lessons
- Lesson 1: what the Booking API is and how the pieces fit
- Lesson 2: keys, allowed domains and authentication
- Lesson 3: GET /company and GET /sizes, the data your screens start from
- Lesson 4: GET /availability, closed days and the service area
- Lesson 5: POST /check and POST /quote, validate and price before the card
- Lesson 6: POST /intents, the server prices the order and opens the payment
- Lesson 7: take the card with Stripe.js on your page
- Lesson 8: POST /confirm and GET /sessions, create the order and never lose one
- Lesson 9: junk removal with the Booking API (/junk/*)
- Lesson 10: webhooks and signature verification
- Lesson 11: errors, limits, idempotency and the security model
- Lesson 12: a complete example in plain JavaScript
- Lesson 13: React, Next.js and AI builders (Claude, Lovable, Codex)
- Lesson 14: go-live checklist and what to tell the office
- Lesson 15: materials, the price depends on what goes in
Lesson 1: what the Booking API is and how the pieces fit
Before the first call, understand the shape of the system. The Booking API is a thin, open door to the same engine that powers the hosted booking page and the widget. You build the screens; the server prices, validates, screens and creates. This lesson gives you the mental model that makes every other lesson obvious.
Before you start: A Dumpster Controls account with an admin login · Online Booking switched on, Stripe connected, at least one active dumpster size (or junk load price)
Three ways to take orders, one engine
Every company can take paid orders online in three ways: the hosted page (dumpstercontrols.io/book?company=slug), the widget (two lines of HTML on any site) and the Booking API (your own screens). All three call the same server functions, apply the same rules and create the same order. The API only removes the screens; it never removes a rule.
The flow in one paragraph
Your page reads the company profile and sizes (GET /company, GET /sizes), shows closed days (GET /availability), validates the customer's choices (POST /check), prices the rental (POST /quote), starts the order and gets a Stripe client_secret (POST /intents), takes the card inside the Stripe Payment Element on your page, and confirms the order (POST /confirm). From then on the company sees the order on its dispatch board, the customer gets the confirmation email and SMS, and your site can be told by a webhook (booking.completed). Junk removal has the same shape under /junk/*.
What the server decides, always
Prices by rental length, tax and fees come from the company's account and are recomputed before the payment intent is created; the API never accepts a price from the client. Booking switched on, Stripe ready, closed days, service area, promo validity and commercial-account emails are checked before any payment. Every card payment runs through Stripe Radar; in the United States suspicious orders are held for manual review (state held). Refunds and cancellations are not in this API.
Vocabulary
session_id: your idempotency id for one booking attempt (you can send it, or the server generates one). payment_intent_id: the Stripe PaymentIntent created by POST /intents. client_secret: the value your page gives to Stripe.js to mount the Payment Element and confirm the card. state: intent_created, confirmed, held, unconfirmed or failed. order_number: the human number of the created order, the same the company sees in the app. total: what the company invoices; gross_total: total plus the processing fee the customer pays under the company's card fee handling.
Where everything lives
Base URL: https://gcwyoiihrupbfqqlcurh.supabase.co/functions/v1/booking-api/v1
Keys and allowed domains: in the app, Online Booking, Developers tab.
Reference pages: https://dumpstercontrols.io/developers/booking (widget) and https://dumpstercontrols.io/developers/booking/api (API). Spec: https://dumpstercontrols.io/openapi-booking.json. Both pages have a markdown twin for AI tools: add .md to the URL.
After: Keep this page open: lessons 2 to 10 go call by call, and lesson 11 is a complete working example you can paste.
Troubleshooting
I only want to show prices on my site, not take orders. Is the API still the right tool? Yes. GET /sizes and POST /quote are enough, and you can link the customer to the hosted page or the widget for payment. The quote the API shows is exactly what the hosted page charges.
The quote on my screen differs from the hosted page for the same size and dates. Check the material. A company that prices by material charges the material price; the hosted page and your screen only match when both name the same material (lesson 15). Without a material you get the general price.
Lesson 1 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-1-what-the-booking-api-is-and-how-the-pieces-fit
Lesson 2: keys, allowed domains and authentication
Two keys, one rule: the publishable key lives in the browser and only identifies the company; the server key lives in your backend and is never shown again. This lesson covers how to get them, how to send them and why a leaked publishable key cannot hurt you.
Before you start: Admin access to Online Booking, Developers
Get the publishable key and add your domain
Open Online Booking, Developers. Under "Open Booking API: your keys" the publishable key (dc_pk_live_...) is already created and shown in full. Under "Allowed domains" add the exact origin of the page that will call the API, for example https://yourcompany.com (https, no path, no wildcard). Browser calls with the publishable key are accepted only from these origins; the app's own origin is always allowed, which is what makes the "Send a test quote" button work.
Send the key
Authorization: Bearer dc_pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Or, if your HTTP client cannot set Authorization, the header X-DC-Key with the same value. From a browser page the request must carry an Origin header (browsers add it to every fetch); the server checks it against your allowed domains and answers with the matching CORS headers. Preflight (OPTIONS) is always answered with 204 so the browser can continue; the real request is the one that decides.
Server key for backend calls
Calls without an Origin header (a server, a script, a cron) must use a server key: click "Create server key" (dc_sk_booking_...). It is shown once; store it in your backend configuration. Up to two can be active, and you can revoke any of them. A server key used from a browser page is refused with secret_in_browser; a publishable key used without Origin is refused with origin_required.
What a leaked publishable key can and cannot do
It can read what your public booking page already shows (sizes, prices, closed days, company profile), ask for quotes, and start a payment intent that still has to pass every rule and the fraud screening, and that only completes when a real card is confirmed. It cannot change a price, a date, a fee, a promo code, or move money. Browser calls are limited to your domains; payment intents are rate-limited per company and per IP. If you still want a fresh key, click "Rotate": the old one keeps working for 24 hours so your site never breaks.
Rate limits
120 requests per minute per key and 120 per minute per IP before authentication, on every plan. A 429 answer carries Retry-After in seconds. There is no daily window and no per-call fee.
After: Test it now: in the Developers tab click "Send a test quote". It calls GET /sizes and POST /quote with your publishable key and shows the JSON.
Troubleshooting
The browser shows a CORS error. The origin of your page is not in Allowed domains, or you typed it with a path or http. Add the exact https origin and retry. Note that the API returns 403 origin_not_allowed without CORS headers on purpose, which the browser reports as a CORS error.
I get 401 unauthorized from Postman or curl. Without an Origin header you need a server key (dc_sk_booking_). Or add the header Origin: https://yourcompany.com to simulate the browser with the publishable key.
Lesson 2 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-2-keys-domains-and-authentication
Lesson 3: GET /company and GET /sizes, the data your screens start from
Your first two calls load everything the booking screen needs to render: who the company is, whether booking is open, how the card fee is handled, and the sizes with their live prices by rental length.
Before you start: Lesson 2 done: a key and, for browser calls, an allowed domain
GET /company
curl -H "Authorization: Bearer dc_pk_live_..." -H "Origin: https://yourcompany.com" \
https://gcwyoiihrupbfqqlcurh.supabase.co/functions/v1/booking-api/v1/company
Response data: id, name, slug, phone, email, address { line1, city, state, zip, country }, currency (usd or cad), country, timezone, branding { logo_url, primary_color, secondary_color, website }, booking { is_online, payments_ready, has_active_sizes, card_fee_handling, non_working_days, service_area { zip, max_miles } or null, rental_agreement, hosted_page }. Use is_online and payments_ready to decide whether to show the booking at all; if either is false, show the company's phone instead.
GET /sizes
Response data: an array sorted by yards. Each size: id (use it in /quote and /intents), label, yards, description, image_url, price_from (starting price in dollars), included_tons, overage_per_ton, prices { d3, d7, d10, d14, d30, extra_day } (dollars; a null tier means the company did not set that length), tax { enabled, percent }, fuel_environmental_fee { enabled, amount }, is_default, is_popular. Only active sizes are returned.
How to show prices honestly
The price for a rental is the tier for the chosen length: up to 3 days d3, up to 7 d7, up to 10 d10, up to 14 d14, up to 30 d30, beyond 30 days d30 plus extra_day per day. When a tier is null the server falls back to price_from plus extra days beyond 7. Do not compute totals yourself for the checkout: call POST /quote (lesson 5) and show its total and processing_fee. Use prices only for the size cards ("from 325").
Card fee handling
booking.card_fee_handling tells you who pays the card processing fee: customer_pays (the customer pays total plus fee, shown as gross_total), company_absorbs (the customer pays total) or split_50_50. Your checkout should show processing_fee and gross_total from /quote exactly as the hosted page does, so there is never a surprise at the card step.
Prices by material (lesson 15)
prices{} on each size are the general tiers. When GET /company says booking.materials.enabled, the company also prices by material: GET /materials lists what goes in the dumpster, which sizes accept each material and the material price per size, which replaces the 7-day base. Material-first screens read /materials before /sizes.
After: Cache these two responses for the page session; they change only when the company edits its settings.
Troubleshooting
GET /sizes returns an empty array. The company has no active size. Ask the office to add sizes and prices in Inventory; booking.has_active_sizes in /company says the same.
Lesson 3 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-3-company-profile-and-sizes-with-live-prices
Lesson 4: GET /availability, closed days and the service area
A good date picker never offers a day the company cannot deliver. This lesson gives you the closed days (holidays, blackout dates, weekly days off, past days) and explains the service area rule so the customer learns early, not at the card step.
Before you start: Lesson 3
GET /availability?from=YYYY-MM-DD&to=YYYY-MM-DD
Defaults: from = today in the company's timezone, to = from plus 59 days. At most 120 days per call. Response data: today, min_date (today), from, to, max_days, closed [ { date, reason } ] where reason is past, holiday_or_blackout or non_working_day. Grey out every date in closed; everything else is open. The hosted page asks for tomorrow onward; the server accepts today onward, so you may offer today if the company handles same-day deliveries by phone.
Where the rules come from
Holidays (US or Canadian, by the company's country) with one switch, custom blackout dates and weekly days off are set by the company in Online Booking, Holidays and Appearance. The API reflects them live. The same rules are enforced again by the server at POST /intents, so a stale picker cannot create an order on a closed day.
Service area
GET /company returns booking.service_area { zip, max_miles } when the company set a delivery radius: the straight-line distance between the company's ZIP and the delivery ZIP must be at or under max_miles. Validate early with POST /check (lesson 5) and show a clear message with the company's phone when the address is outside. If service_area is null, every address is accepted.
After: Re-fetch availability when the customer navigates to a month beyond the loaded range.
Troubleshooting
A day shows open in my picker but /intents answers date_closed. Your availability data is older than a settings change, or your picker ignored the timezone. Re-fetch /availability and compare dates as plain YYYY-MM-DD strings, never through the browser's local Date.
Lesson 4 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-4-availability-closed-days-and-the-service-area
Lesson 5: POST /check and POST /quote, validate and price before the card
Two calls make your checkout feel finished: /check tells the customer right away whether the date, the ZIP and the promo code work; /quote shows the exact money, line by line, that the server will charge.
Before you start: Lessons 3 and 4
POST /check
{ "delivery_date": "2026-11-10", "zip": "32801", "promo_code": "SAVE10" }
Only the fields you send are evaluated. Response data: date { ok, code?, message? } (codes date_past, date_closed), service_area { ok, code?, message?, unverified? } (code out_of_service_area; unverified says why the server could not verify, for example no_coverage when the company has no radius), promo { ok, discount_type, discount_percent, discount_amount } or { ok: false, code: promo_invalid, reason: not_found | inactive | expired | exhausted }, booking_online, payments_ready. Call it when the customer leaves the date field, the ZIP field and the promo field.
POST /quote
{ "size_id": "<uuid from /sizes>", "rental_days": 7, "promo_code": "SAVE10" }
Response data: size_id, rental_days, tier (d3, d7, d10, d14, d30, d30+extra or base_price+extra), currency, lines { base, extra_days, fuel_environmental_fee, promo_discount, subtotal, tax_percent, tax }, promo { code, applied } or null, total and total_cents (what the company invoices), processing_fee and processing_fee_cents (what the customer pays on top under card_fee_handling), card_fee_handling, gross_total and gross_total_cents. Call it every time the size, the rental length or the promo code changes, and show the lines. rental_days is an integer from 1 to 365.
How the promo discount works
A percentage promo applies to the size's starting price (price_from), not to the tier price; a fixed promo subtracts dollars. Both are capped by the server. This mirrors the hosted page exactly, so a customer who compares your site with the hosted link sees the same number.
Money fields
Every amount comes twice: in dollars (total) and in integer cents (total_cents). Use the cents for arithmetic and the dollars for display. Never round on your side; the server already did.
Quote with a material
{ "size_id": "<uuid>", "rental_days": 7, "material": "clean_concrete" }
Response data gains pricing ("material" or "general") and material { slug, label, price, max_weight_tons, overage_mode, overage_per_ton, requires_ack }. With pricing material, lines.base is the material price for 7 days; days beyond 7 use the size's extra day rate; fees and tax are unchanged. A percentage promo applies to the material price. 409 material_not_allowed means the material is not available in that size: details.allowed_sizes lists the sizes that take it. Only companies that price by material are affected; send nothing and you get the general price as before.
After: A quote is not a reservation: nothing is held until POST /intents succeeds and the card is confirmed.
Troubleshooting
/quote answers 404 not_found with field size_id. The size_id belongs to another company or is inactive. Use the ids from GET /sizes of the same key.
Lesson 5 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-5-check-and-quote-before-the-card
Lesson 6: POST /intents, the server prices the order and opens the payment
This is the call that turns a filled form into a payment waiting for a card. You send the order and the customer; the server prices it again, runs every rule and the fraud screening, creates the Stripe PaymentIntent and gives you a client_secret. No price travels from your page to ours.
Before you start: Lesson 5: you have a size_id, rental_days, a delivery date that passed /check and, if used, a valid promo code
Request
POST /intents
{
"size_id": "<uuid>",
"rental_days": 7,
"delivery_date": "2026-11-10",
"pickup_date": "2026-11-17",
"delivery_address": "123 Main St",
"delivery_city": "Orlando",
"delivery_state": "FL",
"delivery_zip": "32801",
"placement_lat": 28.5383,
"placement_lng": -81.3792,
"debris_type": "household",
"customer": { "name": "Jane Doe", "email": "jane@example.com", "phone": "4075550100", "type": "residential", "company_name": null },
"promo_code": "SAVE10",
"terms_accepted": true,
"session_id": "your-own-id-optional"
}
Required: size_id, rental_days (1 to 365), delivery_date (YYYY-MM-DD), delivery_address (street number and name), delivery_zip, customer.name, customer.email, customer.phone, terms_accepted = true. pickup_date defaults to delivery_date plus rental_days. customer.type is residential or contractor. session_id is 8 to 64 characters (letters, digits, _ or -); send your own to make retries idempotent.
Response
{ "data": {
"session_id": "...", "payment_intent_id": "pi_...", "client_secret": "pi_..._secret_...",
"stripe": { "publishable_key": "pk_live_...", "account": null },
"currency": "usd",
"amount": { "total": 350, "total_cents": 35000, "processing_fee": 14.46, "processing_fee_cents": 1446, "gross_total": 364.46, "gross_total_cents": 36446, "card_fee_handling": "customer_pays" },
"next": "Confirm the card with Stripe.js ..."
} }
Keep session_id, payment_intent_id and client_secret in memory for the next two lessons. For Canadian companies stripe.account is the connected account id: initialize Stripe.js with it (lesson 7).
What happens on the server
In order: the input is validated (400 invalid_body with field), the price is recomputed from the company's account, booking must be on and Stripe ready, the date must be open and the ZIP inside the service area, the promo must be valid, the email must not belong to a commercial account of the company (409 account_customer), the rate limits and the card-testing gate are checked, the card fee is computed under the company's handling, and the PaymentIntent is created on Stripe with the risk tier of the company (manual capture for review when the company holds suspicious orders). A record of the attempt is kept so the company can see abandoned checkouts.
Errors you should handle
400 invalid_body: show the message next to the field. 409 booking_offline, payments_not_ready: show the company's phone. 409 date_past, date_closed, out_of_service_area: send the customer back to that step with the message. 409 account_customer: the message tells the customer to order through their account or call. 409 already_authorized: this session already has an authorized payment; go straight to POST /confirm with its payment_intent_id. 429 rate_limited: wait Retry-After seconds. 502 upstream: try again in a few seconds.
Idempotency
Send the same session_id on a retry and the server reuses the open PaymentIntent instead of creating a new one. If the payment was already authorized you get 409 already_authorized with the hint to confirm. Generate a new session_id only for a genuinely new attempt (a different size, date or customer).
material and material_acknowledged
Send material (a slug from GET /materials) when the company prices by material; the server prices the order by it and the order carries the material. When that material has requires_ack, send material_acknowledged: true after showing the explanation; otherwise 400 invalid_body with field material_acknowledged and the explanation in the message. A debris_type equal to an active material slug is treated as the material; sending both with different values is 400. 409 material_not_allowed works as in POST /quote. The price is resolved once here and frozen with the payment: editing prices in the account afterwards does not change this order.
After: Nothing has been charged yet. The customer still has to confirm the card (lesson 7) and your page still has to call POST /confirm (lesson 8).
Troubleshooting
The amount in /intents differs from my last /quote. The company changed a price or a promo between the two calls, or the promo became invalid. Always show the amount from /intents on the payment step; it is what the card will be charged.
Lesson 6 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-6-start-the-order-with-post-intents
Lesson 7: take the card with Stripe.js on your page
The card is entered inside Stripe's Payment Element on your page, so card data never touches your server or ours. You initialize Stripe.js with the publishable key from the /intents response, mount the element with the client_secret, and confirm. This lesson is the exact code.
Before you start: Lesson 6: a client_secret and the stripe object from the response · Stripe.js loaded on your page: https://js.stripe.com/v3/
Load Stripe.js and initialize
<script src="https://js.stripe.com/v3/"></script>
const intent = /* data from POST /intents */;
const stripe = Stripe(intent.stripe.publishable_key, intent.stripe.account ? { stripeAccount: intent.stripe.account } : {});
const elements = stripe.elements({ clientSecret: intent.client_secret, appearance: { theme: "stripe" } });
const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");
For Canadian companies stripe.account is set and the stripeAccount option is mandatory: the PaymentIntent lives on the company's connected Stripe account. For US companies it is null and the intent lives on the platform account.
### Confirm the card
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: window.location.href },
redirect: "if_required",
});
if (error) { showMessage(error.message); return; }
// paymentIntent.status is "succeeded" or "requires_capture" (review hold): both mean authorized.
await confirmOrder(intent.session_id, intent.payment_intent_id); // lesson 8
redirect: "if_required" keeps the customer on your page for cards; wallets or bank redirects return to return_url with the PaymentIntent id in the query string, so your return page must call POST /confirm as well.
### What the customer should see
The amount on the pay button must be gross_total from /intents (what the card is charged), with processing_fee shown as its own line when card_fee_handling is customer_pays or split_50_50. Show the company's rental agreement (GET /company booking.rental_agreement: use_standard or custom_content) and a checkbox the customer ticks before paying; that is the terms_accepted you already sent as true.
### Testing without a real card
There is no sandbox: the engine runs on live Stripe accounts. For a true end-to-end test, book a 10 dollar order on your own company with a real card, then refund it from Order History. Quotes, checks and intents cost nothing; an intent that is never confirmed expires on its own.
**After:** Right after confirmPayment resolves without error, call POST /confirm (lesson 8). Do not wait for a webhook to show the order number.
### Troubleshooting
**Stripe.js says the client_secret does not belong to this account.** A Canadian company: you initialized Stripe without stripeAccount. Pass { stripeAccount: intent.stripe.account }.
**The card was declined.** Show error.message from confirmPayment and let the customer try another card. The same session_id and client_secret stay valid; no new /intents call is needed.
Lesson 7 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-7-take-the-card-with-stripe-js-on-your-page
## Lesson 8: POST /confirm and GET /sessions, create the order and never lose one
After the card is authorized, one call creates the order, the customer, the invoice and the payment record, sends the confirmations and puts the order on the dispatch board. If your page dies in between, the same call can be repeated at any time while the authorization is valid, and GET /sessions tells you where things stand.
**Before you start:** Lesson 7: confirmPayment returned without error
### POST /confirm
{ "session_id": "...", "payment_intent_id": "pi_..." }
Response data: state (confirmed, or held when the company reviews suspicious orders), order_number, order_id, invoice_id, customer_id. Idempotent: calling it again returns the same order with already: true. Show order_number to the customer and fire your conversion tags now.
### The held state
Some US companies hold suspicious orders for a manual review before capturing the money. Then state is held: the order exists, the customer gets a "received, under review" message instead of a confirmation, and the company approves or declines in the app. Show a calm message: the order was received and the company will confirm shortly. When the company approves, your webhook (lesson 10) gets booking.released.
### If /confirm fails
409 unconfirmed: the message says why (for example the card was not authorized yet); fix and retry. 409 payment_intent_mismatch: you mixed sessions; use the pair from the same /intents response. 502 upstream: our order service did not answer. In every case the authorized payment is kept and you can call POST /confirm again with the same pair; it is idempotent and works as long as the authorization is valid. Nobody is charged without an order: the authorization is only captured when the order is created, and an authorization that never becomes an order is released or expires on its own. Within about 15 minutes of an authorized card without an order, the customer and the company are told by email and SMS so the office can finish it by hand.
### GET /sessions/:session_id
Response data: session_id, service (dumpster or junk), state, payment_intent_id, order_number, order_id, job_number, job_id, last_error, updated_at. Poll it (every 5 seconds, up to a few minutes) when /confirm did not answer or when a return_url flow lands on a fresh page; stop when state is confirmed or held.
### What to persist on your side
Keep session_id and payment_intent_id in sessionStorage during the checkout and in your own database if you want to reconcile later. order_number is the key the company uses in the app and in every webhook payload.
### material in the session
GET /sessions/:id also returns amount_cents and material { slug, label, acknowledged_at } when the session was quoted, so support can see which material the server priced.
**After:** The customer gets email and SMS; the office gets email, SMS, push and an in-app alert with sound; the order is on the dispatch board already paid.
### Troubleshooting
**Can I call /confirm before the card is confirmed?** It answers 409 unconfirmed with the Stripe status. Call it only after confirmPayment resolves without error, or after your return page loads with the PaymentIntent id.
Lesson 8 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-8-confirm-the-order-and-recover-with-sessions
## Lesson 9: junk removal with the Booking API (/junk/*)
Junk removal has its own hosted page, its own prices (by load size), its own arrival windows and optional photos of the material. The API mirrors it under /junk/* with the same keys, the same session and the same Stripe flow, so one integration can offer both services.
**Before you start:** Lessons 2, 6, 7 and 8 (the dumpster flow); the company has junk removal switched on in Online Booking
### GET /junk/config
Response data: junk_online, payments_ready, hosted_page (dumpstercontrols.io/junk-checkout/slug), loads [ { load_fraction, price, notes, is_default, sort_order } ] (for example 1/4, 1/2, 3/4, full, with the price in dollars), windows [ { start, end } ] (HH:MM arrival windows), same_day { enabled, cutoff }, max_advance_days, photo (off or optional), locations (curbside, inside_home, garage, construction_site), not_accepted [], extra_fee [], terms { use_standard, custom_content }, cancellation_policy, card_fee_handling. Render the load cards from loads and the window picker from windows.
### GET /junk/availability?from&to
Response data: today, from, to, max_advance_days, same_day, days [ { date, open, windows [ { start, end } ], reason } ]. For each day you get the windows that still fit (same-day cutoff, max advance, weekly days off, holidays and blackout dates are already applied). reason, when a day is closed: past_date, same_day_closed, too_far, non_working_day, blackout or no_window.
### POST /junk/quote
{ "load_fraction": "1/2", "promo_code": "SAVE10" }
Response data: load_fraction, currency, lines { base, promo_discount }, promo, total and total_cents, processing_fee and processing_fee_cents, card_fee_handling, gross_total and gross_total_cents, note. Junk is priced by load size at booking; the crew confirms the load on site and the company handles any difference afterwards (load adjustment), outside this API.
### POST /junk/intents
{
"load_fraction": "1/2",
"job_date": "2026-11-10", "time_window_start": "08:00", "time_window_end": "12:00",
"address_line1": "123 Main St", "city": "Orlando", "state": "FL", "zip": "32801",
"job_type": "garage", "notes": "Gate code 1234", "crew_size": 2,
"placement_lat": 28.5383, "placement_lng": -81.3792,
"customer": { "name": "Jane Doe", "email": "jane@example.com", "phone": "4075550100" },
"promo_code": "SAVE10", "terms_accepted": true, "session_id": "optional"
}
Required: load_fraction, job_date, time_window_start and time_window_end (a window from /junk/config or /junk/availability), address_line1, zip, customer, terms_accepted = true. The response has the same shape as the dumpster /intents (session_id, payment_intent_id, client_secret, stripe, amount). Slot errors come back as 409 slot_past_date, slot_same_day_closed, slot_too_far, slot_non_working_day, slot_blackout, slot_bad_window or slot_bad_date with field job_date.
### Card, confirm and photos
Take the card exactly as in lesson 7. Then POST /junk/confirm { session_id, payment_intent_id } creates the job: response data state confirmed, job_number, job_id, photos (a note when photos are accepted). When the company accepts photos, send up to 3 within 2 hours with POST /junk/photos { session_id, content_base64 } (JPEG, PNG or WebP, 8 MB max; the response carries the stored url). GET /sessions/:id works for junk sessions too, with job_number and job_id.
**After:** The job appears in Junk Removal on the dispatch board, the customer gets the confirmation, and the company's junk webhooks and messages work as for any online junk job.
### Troubleshooting
**/junk/quote answers not_found.** The load_fraction is not one of the company's active load prices. Use exactly the load_fraction strings from GET /junk/config.
**/junk/intents answers booking_offline but dumpster /intents works.** Junk removal has its own online switch in Online Booking (the junk card). The company has to turn it on.
Lesson 9 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-9-junk-removal-with-the-booking-api
## Lesson 10: webhooks and signature verification
Webhooks tell your site or CRM that an order landed, without polling. They are registered in the app, signed with HMAC-SHA256, retried with backoff, and need no approval. This lesson covers the events, the payload, the signature check and idempotency.
**Before you start:** Admin access to Online Booking, Developers · An https endpoint on a public domain
### Register an endpoint
In Online Booking, Developers, under "Webhooks", enter an https URL, pick the events and click Add. The signing secret (dcwh_...) is shown once; keep it in your backend. Up to 3 endpoints. Private and local hosts are refused; https is required.
### Events
booking.completed: an online order was created and confirmed (hosted page, widget or API). booking.held: an online order was received but held for manual review. booking.released: a held order was approved and left the review. Payload data: order_number, status, payment_status, delivery_date, pickup_date, total_price, booking_channel (link, widget or api), order_id, customer_ref, created_at, plus previous_status on booking.released.
### Delivery and signature
POST https://your-endpoint
Content-Type: application/json
X-DC-Signature: t=1760000000,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with your dcwh_ secret>
{ "event": "booking.completed", "created_at": "2026-10-05T12:00:00Z", "data": { ... } }
Answer 2xx within 10 seconds. Anything else is retried with backoff at roughly 1, 5, 30, 120 and 360 minutes; after the fifth failure the event is dropped and the company's admins are emailed. After 20 consecutive failures the endpoint is switched off.
Verify in Node
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const given = String(parts.v1 || "");
if (given.length !== 64) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(given, "hex"));
}
Use the raw request body, not a re-serialized JSON. The Python version is in the data API manual: How to verify webhook signatures.
Idempotency
Deliveries are at-least-once. Key your handler on order_number plus event: a second delivery of the same event must be a no-op.
material in the payload
booking.completed, booking.held and booking.released carry material (the slug, or the free text the order has) and material_label (from the order's material snapshot). Both are null when the order has no material. New keys may appear in data over time; never reject unknown keys.
After: Webhooks are optional: POST /confirm already returns the order number synchronously. Use them for your CRM, your inbox or your own dashboard.
Troubleshooting
My endpoint was switched off. Twenty consecutive failures (non-2xx or timeout). Fix the endpoint, delete it and add it again in the app.
Lesson 10 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-10-webhooks-and-signature-verification
Lesson 11: errors, limits, idempotency and the security model
A reference you will come back to: the error envelope and every code, the limits, what is idempotent, and what the server guarantees no matter what your page sends.
Before you start: Any lesson
The error envelope
{ "error": { "code": "invalid_body", "message": "rental_days must be an integer between 1 and 365.", "field": "rental_days" } }
code is stable and meant for your logic; message is meant for the customer and may change wording; field names the offending input when there is one.
Codes by status
400 invalid_body, invalid_parameter, intent_rejected, photo_rejected. 401 unauthorized, origin_required. 403 origin_not_allowed, secret_in_browser, company_inactive. 404 not_found. 405 method_not_allowed. 409 booking_offline, payments_not_ready, date_past, date_closed, out_of_service_area, account_customer, already_authorized, payment_intent_mismatch, unconfirmed, processing, photos_not_available, slot_* (junk). 410 photos_expired. 413 photo_too_large. 415 photo_type. 429 rate_limited (Retry-After). 500 internal. 502 upstream. 503 booking_api_off.
Limits
120 requests per minute per key and per IP; payment intents are also capped per company and per IP against card testing. No daily window, no per-call fee, on every plan. Availability: at most 120 days per call. Photos: 3 per job within 2 hours, 8 MB each.
Idempotency
/intents with the same session_id reuses the open PaymentIntent. /confirm is idempotent per session: a second call returns the same order with already: true. Webhooks are at-least-once; dedupe on order_number plus event.
What the server guarantees
Prices, tax and fees are recomputed from the company's account; no client price is ever used. Booking on, Stripe ready, closed days, service area, promo validity and commercial-account emails are enforced before any payment. Every card payment is screened by Stripe Radar; US companies may hold suspicious orders for review. Card data stays inside the Stripe Payment Element. The publishable key only identifies the company; the server key is hashed at rest. Refunds, cancellations and price changes are not reachable through this API. Everything the API returns is what the public booking page already shows.
details in the error envelope
Some errors add details next to code, message and field: invalid_body for an unknown material carries details.allowed (slugs); material_not_allowed (409) carries details.material and details.allowed_sizes (id, label, yards, price). Treat details as optional.
After: If you build a client library, map the codes above to typed errors and treat every other code as a generic failure with the message.
Troubleshooting
I get 503 booking_api_off. The API is switched off platform-wide for maintenance. The hosted page and the widget keep working; retry later.
Lesson 11 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-11-errors-limits-idempotency-and-security
Lesson 12: a complete example in plain JavaScript
One HTML page that loads sizes, quotes, validates, starts the order, takes the card and confirms. Paste it, replace the key, add your domain in the app, and you have a working booking. Every function maps to one lesson.
Before you start: Lessons 2 to 8
The page
<div id="sizes"></div>
<input id="days" type="number" value="7" min="1" max="365">
<input id="date" type="date">
<input id="address" placeholder="123 Main St"> <input id="zip" placeholder="ZIP">
<input id="name" placeholder="Full name"> <input id="email" placeholder="Email"> <input id="phone" placeholder="Phone">
<label><input id="terms" type="checkbox"> I accept the rental agreement</label>
<div id="quote"></div>
<div id="payment-element"></div>
<button id="pay">Pay</button>
<div id="result"></div>
<script src="https://js.stripe.com/v3/"></script>
The client
const API = "https://gcwyoiihrupbfqqlcurh.supabase.co/functions/v1/booking-api/v1";
const KEY = "dc_pk_live_..."; // your publishable key; add this page's domain in the app
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function api(path, body) {
const res = await fetch(API + path, body ? { method: "POST", headers, body: JSON.stringify(body) } : { headers });
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error?.message || res.statusText), { code: json.error?.code, field: json.error?.field });
return json.data;
}
let sizeId = null, intent = null, stripe = null, elements = null;
(async () => {
const sizes = await api("/sizes");
document.getElementById("sizes").innerHTML = sizes.map((s) => `<button data-id="${s.id}">${s.label} from $${s.price_from}</button>`).join("");
document.querySelectorAll("#sizes button").forEach((b) => b.onclick = () => { sizeId = b.dataset.id; refreshQuote(); });
document.getElementById("days").onchange = refreshQuote;
})();
async function refreshQuote() {
if (!sizeId) return;
const q = await api("/quote", { size_id: sizeId, rental_days: Number(document.getElementById("days").value) });
document.getElementById("quote").textContent = `Total $${q.total} + processing fee $${q.processing_fee} = $${q.gross_total}`;
}
Start, pay, confirm
document.getElementById("pay").onclick = async () => {
const out = document.getElementById("result");
try {
if (!intent) {
intent = await api("/intents", {
size_id: sizeId, rental_days: Number(document.getElementById("days").value),
delivery_date: document.getElementById("date").value,
delivery_address: document.getElementById("address").value, delivery_zip: document.getElementById("zip").value,
customer: { name: document.getElementById("name").value, email: document.getElementById("email").value, phone: document.getElementById("phone").value },
terms_accepted: document.getElementById("terms").checked,
});
stripe = Stripe(intent.stripe.publishable_key, intent.stripe.account ? { stripeAccount: intent.stripe.account } : {});
elements = stripe.elements({ clientSecret: intent.client_secret });
elements.create("payment").mount("#payment-element");
out.textContent = `Enter your card. You will be charged $${intent.amount.gross_total}.`;
return; // second click pays
}
const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: location.href }, redirect: "if_required" });
if (error) { out.textContent = error.message; return; }
const c = await api("/confirm", { session_id: intent.session_id, payment_intent_id: intent.payment_intent_id });
out.textContent = c.state === "held" ? `Received, under review. Reference ${c.order_number}` : `Booked! Order ${c.order_number}`;
} catch (e) {
out.textContent = e.message; // 409 messages are written for the customer
}
};
What to add for production
A date picker that greys out GET /availability closed days; POST /check on blur of date, ZIP and promo; the rental agreement text from GET /company; your Google Ads or Meta Pixel conversion after /confirm; GET /sessions polling on the return_url page; and a friendly fallback with the company's phone when booking.is_online or payments_ready is false.
Materials in the example
const mats = await fetch(`${BASE}/materials`, { headers }).then(r => r.json());
if (mats.data.enabled) {
// ask the material first; keep only the sizes it allows (sizes[] when size_policy is listed, else all)
const m = mats.data.materials.find(x => x.slug === chosenSlug);
// then quote and start the order with material (and material_acknowledged when m.requires_ack)
body.material = m.slug; if (m.requires_ack) body.material_acknowledged = true;
}
On 409 material_not_allowed read err.error.details.allowed_sizes and offer those sizes.
After: The same skeleton serves junk removal: swap /sizes for /junk/config loads, /quote for /junk/quote, /intents for /junk/intents and /confirm for /junk/confirm.
Troubleshooting
The Payment Element does not render. Stripe.js must be loaded before you call Stripe(), the client_secret must come from the same /intents response, and for Canadian companies stripeAccount is required.
Lesson 12 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-12-complete-example-in-plain-javascript
Lesson 13: React, Next.js and AI builders (Claude, Lovable, Codex)
The same calls inside a React component, the rules that keep a server key out of the bundle, and the prompt that makes an AI coding tool build the whole checkout from this course.
Before you start: Lesson 12
React component outline
import { useEffect, useState } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
const API = "https://gcwyoiihrupbfqqlcurh.supabase.co/functions/v1/booking-api/v1";
const KEY = process.env.NEXT_PUBLIC_DC_BOOKING_KEY; // publishable key only, never the server key
async function api(path, body) { /* same helper as lesson 12 */ }
export default function Book() {
const [intent, setIntent] = useState(null);
const start = async (form) => setIntent(await api("/intents", form));
if (!intent) return <OrderForm onSubmit={start} />;
const stripePromise = loadStripe(intent.stripe.publishable_key, intent.stripe.account ? { stripeAccount: intent.stripe.account } : undefined);
return (
<Elements stripe={stripePromise} options={{ clientSecret: intent.client_secret }}>
<PayStep intent={intent} />
</Elements>
);
}
function PayStep({ intent }) {
const stripe = useStripe(); const elements = useElements();
const pay = async () => {
const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: window.location.href }, redirect: "if_required" });
if (error) return alert(error.message);
const c = await api("/confirm", { session_id: intent.session_id, payment_intent_id: intent.payment_intent_id });
alert(c.state === "held" ? Received, under review: ${c.order_number} : Booked: ${c.order_number});
};
return <><PaymentElement /><button onClick={pay}>Pay ${intent.amount.gross_total}</button></>;
}
### Next.js and server keys
If you call the API from a Route Handler or a server action, use the server key from a server-only environment variable (DC_BOOKING_SERVER_KEY) and never prefix it with NEXT_PUBLIC_. Browser calls use the publishable key and must come from an allowed domain; add your preview domains too while developing.
### Builders that generate React (Lovable, v0)
They can call the API directly from the generated components with the publishable key. Tell them the allowed domain is the published site domain, and to read the API reference first. If they try to build a form that posts a price, say no: prices come from POST /quote and the card from the Payment Element.
### The prompt for an AI tool
Build custom online booking screens for my dumpster rental website using the Dumpster Controls Booking API. Read https://dumpstercontrols.io/developers/booking/api.md first and follow it exactly. My publishable key is "{publishable_key}" and my allowed domain is "{domain}". Flow: GET /sizes to show the sizes with live prices; GET /materials first and, when enabled is true, ask what the customer is disposing of before the size, show only the sizes allowed for that material with the material price, send material to POST /quote and POST /intents, show the explanation and require the acknowledgement (material_acknowledged: true) when requires_ack is true, and on 409 material_not_allowed offer details.allowed_sizes; POST /quote whenever the size, material or rental days change and show total and processing_fee; POST /check with the delivery date, ZIP and promo code before payment; POST /intents with the full order and customer to get session_id, payment_intent_id and client_secret; mount the Stripe Payment Element with client_secret using the publishable key from the response (and stripeAccount when stripe.account is set), confirm the card with stripe.confirmPayment and redirect "if_required"; then POST /confirm with session_id and payment_intent_id and show order_number. Never compute prices on the client, never collect card data outside the Stripe Payment Element, and never store the server key in the browser. Handle 409 errors by showing the message from the API. Fire my Google Ads and Meta Pixel conversions after /confirm succeeds. If the company also offers junk removal, use the /junk/* endpoints as documented (GET /junk/config, GET /junk/availability, POST /junk/quote, POST /junk/intents, POST /junk/confirm, POST /junk/photos) with the same keys and the same Stripe flow.
**After:** The same prompt, with your key and domain already filled in, is in Online Booking, Developers (Prompt 3).
### Troubleshooting
**My bundle contains dc_sk_booking_.** A server key leaked into client code. Revoke it in the Developers tab, create a new one and keep it server-only.
Lesson 13 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-13-react-next-js-and-ai-builders
## Lesson 14: go-live checklist and what to tell the office
The last mile: what to verify before pointing real customers at your screens, what the office will see, and how to support the first orders.
**Before you start:** Lessons 1 to 13
### Before the first customer
1. GET /company shows is_online true and payments_ready true. 2. GET /sizes returns every size the office expects, with the right prices. 3. Closed days from GET /availability match the office calendar. 4. POST /check refuses an out-of-area ZIP and accepts an in-area one. 5. A 10 dollar real order goes through /intents, the card and /confirm, shows on the dispatch board and is refunded from Order History. 6. Conversion tags fire after /confirm. 7. The rental agreement is shown and the checkbox is required. 8. Your domain is in Allowed domains; staging domains too if used. 9. The server key, if any, is not in client code.
### What the office sees
The order appears on the dispatch board already paid, with channel api on the order; the customer appears in Customers; the invoice is created; the office gets email, SMS, push and the in-app alert with sound. Held orders appear in the review queue. Nothing else changes for the office: your screens are just another door.
### Support and monitoring
Log session_id, payment_intent_id and order_number on your side for every attempt. Watch 409 codes: a spike of out_of_service_area or date_closed means the office changed settings and your cached data is stale. Watch 429: you are calling /quote too often; debounce the inputs. Watch upstream (502): retry POST /confirm with the same pair after a few seconds and read GET /sessions to see the state.
### Where to ask
support@dumpstercontrols.io for the API, the office of the company for prices and rules, and Tresha inside the app for anything in this manual.
### Materials
If GET /materials says enabled: your screens ask the material before the size, show the explanation and the confirmation when requires_ack is true, send material to /quote and /intents, and handle 409 material_not_allowed by offering details.allowed_sizes. Compare one quote per material against the office's price list.
**After:** Done. From here the engine keeps improving behind your screens: every fix we ship to the hosted page reaches your integration with no change on your side.
### Troubleshooting
**A customer says the card was charged but sees no order number.** The /confirm call did not reach us (page closed, network). The card was authorized, not charged: Nobody is charged without an order: the authorization is only captured when the order is created, and an authorization that never becomes an order is released or expires on its own. Within about 15 minutes of an authorized card without an order, the customer and the company are told by email and SMS so the office can finish it by hand. Look the session up with GET /sessions/:id, and if the authorization is still valid call POST /confirm again with the same session_id and payment_intent_id.
Lesson 14 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-14-go-live-checklist
## Lesson 15: materials, the price depends on what goes in
Some companies charge a different price for the same dumpster depending on what goes in it (clean concrete, asphalt, brick, soil) and only accept heavy materials in smaller sizes. This lesson covers GET /materials and the material field on POST /quote and POST /intents. The company configures all of it in the app (Inventory, Materials: which sizes accept each material, the price per size, included tons, the switch "Pricing by material", off by default); this API only reads it, there is no endpoint to create or edit materials. Nothing changes for a company that does not price by material.
**Before you start:** Lessons 3, 5 and 6 · A company that turned on pricing by material in Inventory, Materials (off by default)
### Read the materials
GET /materials returns data { enabled, required, materials[], note }. enabled means the company prices by material; required means ask the material before the size. Each material has slug, label, explanation (what counts as that material), requires_ack (the customer must confirm the explanation), is_catch_all (the general option that is always available), is_heavy, size_policy (all or listed) and sizes[]: with listed, only those sizes accept the material; each size carries price (replaces the 7-day base; null = the size price), max_weight_tons, overage_mode (inherit, flat, per_ton) and overage_per_ton.
### Material-first screens
When required is true: step 1 asks what the customer is disposing of (the materials, in sort_order, with label and explanation), step 2 shows only the sizes that material allows (sizes[] when size_policy is listed, every size from GET /sizes when all) at the material price, then dates, address and the card. When enabled is false keep your usual size-first flow.
### Quote and start with a material
POST /quote { size_id, rental_days, material } returns pricing material or general, material {...} and the usual lines: with pricing material, base is the material price for 7 days, days beyond 7 use the size's extra day rate, fees and tax are unchanged, and a percentage promo applies to the material price. POST /intents takes the same material plus material_acknowledged: true when requires_ack is true. The server resolves the material once and freezes it with the payment, so the order keeps that price even if the office edits it later.
### Errors to handle
400 invalid_body field material: unknown slug (details.allowed lists the valid ones). 400 invalid_body field material_acknowledged: the material requires the customer's confirmation (the explanation is in the message). 400 invalid_body field debris_type: you sent material and a different debris_type. 409 material_not_allowed: the material is not available in that size; details.allowed_sizes (id, label, yards, price) are the sizes to offer.
### Compatibility
Clients that never send material keep today's prices and responses. One exception: on a company that prices by material, a debris_type equal to an active material slug (for example concrete) is priced as that material and may be refused with material_not_allowed. Send material explicitly to be precise.
### What the office sees
The order shows the material, the invoice line reads size and material (for example 10 Yard, Clean Concrete) with the included weight of that rule, and the order keeps a snapshot of the rule the customer accepted, with the acknowledgement time. Webhooks carry material and material_label.
**After:** Test one quote per material against the office's price list before going live; the Developers tab's test quote already uses the first priced material when the company has one.
### Troubleshooting
**GET /materials returns enabled false but the office configured prices.** Pricing by material is off by default for every company. The office turns it on with the switch at the top of Inventory, Materials; until then the list is visible but every material costs the size price.
**The same size costs less with a material than without.** That is the company's choice: some materials (clean concrete) are cheaper to dispose of. The material price replaces the size price; it is not added to it.
Lesson 15 of 15. Article: https://dumpstercontrols.io/help/booking-api/lesson-15-materials-the-price-depends-on-what-goes-in
## FAQ
### Do I need approval or a paid plan to use the Booking API?
No. Every active company has its keys in Online Booking, Developers. There is no request, no review and no usage window. The limit is 120 requests per minute per key and per IP.
### Can the API change prices, refund or cancel?
No. Money moves only from the customer to the company. Prices, dates, promo codes and fraud screening are decided on the server; refunds, cancellations and price changes stay in the app and in the approval-gated data API.
### Does the order created by the API look different in the app?
No. It is created by the same function as the hosted page and the widget: customer, invoice, payment record, confirmations and the dispatch board entry are identical. Only the channel recorded on the order says api.
### Is there a sandbox?
Not yet. Quotes and checks cost nothing. For a real end-to-end test, book a 10 dollar order on your own company with a real card and refund it from Order History.
Page URL: https://dumpstercontrols.io/developers/booking/guide. Machine-readable version: https://dumpstercontrols.io/developers/booking/guide.md. Spec: https://dumpstercontrols.io/openapi-booking.json. Updated 2026-10-05.
