Partners
EstateReels API
Send a listing, get back a finished 9:16 video. The API is JSON over HTTPS at https://estatereels.ae/api/v1. Field names are snake_case, timestamps are ISO-8601 UTC and money is integer fils. Ignore fields you do not recognise: we add fields without changing the version. Need access? See partner pricing.
Quickstart
Ask us for a test key, then run these four calls. Test keys start with er_test_, spend no credits and return a sample video, so you can build the whole integration before going live. Save the request body below as render.json.
{
"template": "BasicEditorial",
"listing": {
"title": "Upgraded 2BR | Marina View | Vacant",
"purpose": "sale",
"price_aed_fils": 245000000,
"property_type": "apartment",
"furnishing": "unfurnished",
"bedrooms": 2,
"bathrooms": 3,
"bua_sqft": 1320,
"community": "Dubai Marina",
"tower": "Marina Gate 1",
"city": "Dubai",
"permit_number": "7112345678",
"images": [
"https://cdn.example.com/listings/8841/1.jpg",
"https://cdn.example.com/listings/8841/2.jpg"
]
},
"audience_persona": "investor",
"power_ups": ["enhance_photos"],
"branding": {
"agent_name": "Sara Khan",
"agent_title": "Senior Consultant",
"agent_phone": "+971500000000",
"agent_whatsapp": "+971500000000",
"agency_name": "Example Realty"
},
"external_ref": "crm-listing-8841",
"metadata": { "crm_user_id": "u_193" }
}# 1. List the templates your key can use
curl https://estatereels.ae/api/v1/templates \
-H "Authorization: Bearer $ER_API_KEY"
# 2. Price an order without creating it
curl https://estatereels.ae/api/v1/render_quotes \
-H "Authorization: Bearer $ER_API_KEY" \
-H "Content-Type: application/json" \
-d @render.json
# 3. Create the render (Idempotency-Key is required)
curl https://estatereels.ae/api/v1/renders \
-H "Authorization: Bearer $ER_API_KEY" \
-H "Idempotency-Key: crm-listing-8841-v1" \
-H "Content-Type: application/json" \
-d @render.json
# 4. Poll it, or wait for the render.succeeded webhook
curl https://estatereels.ae/api/v1/renders/RENDER_ID \
-H "Authorization: Bearer $ER_API_KEY"POST /renders answers 202 with a render object whose status is queued. When it reaches succeeded the object carries an output.video_url. Copy the file into your own storage: signed links expire.
Authentication
Send your key as a bearer token on every request: Authorization: Bearer er_live_... or er_test_.... The prefix decides the mode. Keys are created and revoked from the Partner dashboard and are shown once, when created. Treat them like passwords: keep them on your server, never in browser or mobile code.
A missing, malformed, revoked or unknown key returns 401 invalid_api_key. A partner account that is not active returns 403 partner_suspended. Every response carries a Request-Id header; quote it if you contact us.
Endpoints
| Method and path | What it does |
|---|---|
GET /templates | Templates your key can use, with credit cost, supported power-ups and whether a presenter is required. |
POST /render_quotes | Same body as create. Returns the credit cost and a breakdown without creating anything. |
POST /renders | Create a render. Asynchronous. Requires an Idempotency-Key header. |
GET /renders/{id} | Current state of one render. Output links are signed fresh on every call and last 1 hour. |
GET /renders | List renders, newest first. Filters: status, external_ref, created_after. Cursor pagination with starting_after and limit (1 to 100, default 10). |
GET /balance | Your credit pool: credits, livemode and low_balance_threshold. |
GET /webhook_endpoints | List your webhook endpoints in this mode. |
POST /webhook_endpoints | Register an endpoint: url and optional enabled_events. The response includes the signing secret once. |
GET, POST, DELETE /webhook_endpoints/{id} | Read one endpoint, update it (url, enabled_events, or status enabled or disabled) or delete it. |
POST /webhook_endpoints/{id}/rotate_secret | Issue a new secret. The old one keeps working for 24 hours by default. Optional expire_previous_after_seconds from 0 to 86400. |
POST /webhook_endpoints/{id}/test | Queue a signed ping event to that endpoint. |
GET /events | The last 30 days of events, newest first. Filters: type, created_after. Same pagination as renders. Output links in render.succeeded events are signed fresh for 1 hour. Use it to catch up after downtime. |
GET /events/{id} | One event. |
Lists return { "object": "list", "data": [...], "has_more": false, "livemode": true }. Request bodies are limited to 100 KB.
Creating a render
Send exactly one of listing (the listing data) or listing_url (a Property Finder link; other portals return 400 unsupported_listing_url). Unknown fields are rejected, so a typo is caught instead of ignored.
| Field | Notes |
|---|---|
template | Required. A template slug from GET /templates. |
listing | title, purpose (sale or rent), price_aed_fils, property_type, furnishing and permit_number are required; images is 1 to 20 https URLs. Optional: rent_period, bedrooms, bathrooms, bua_sqft, plot_sqft, service_charge_aed_fils, floor, total_floors, parking, community, sub_community, tower, city, description (up to 3000 characters). |
property_type | apartment, villa, townhouse, penthouse, studio, duplex, loft, office, retail, warehouse, land or other. |
furnishing | furnished, semi_furnished or unfurnished. |
audience_persona | investor, young_professional, family or generic (default generic). Changes the script's emphasis. |
power_ups | Any of enhance_photos, extra_photos, ai_motion, four_k that the template supports. Each has its own credit cost in GET /templates. |
presenter | Required when the template has requires_presenter. Either {"mode":"prebuilt","prebuilt_key":"emirati-woman"} or {"mode":"random","gender":"female"}. Omit it for other templates. |
branding | Required. agent_name and agent_phone are required. Optional: agent_title, agent_whatsapp, agent_photo_url, agency_name, agency_logo_url, agency_orn, agent_brn. Shown on the video's closing card. |
external_ref, metadata | Your own reference (up to 255 characters) and up to 20 string key-value pairs. Both come back on the render and in every event. |
Photo and logo URLs must be public https links. We fetch and store copies when the render starts, so the links only need to work for a short time.
Idempotency. Send an Idempotency-Key of 1 to 255 printable characters with no spaces. Repeating a request with the same key and the same body returns the original render with 200 and an Idempotent-Replayed: true header, so a retry after a timeout never creates a second video or a second charge. The same key with a different body returns 409 idempotency_conflict. Market Report templates are not available through the API.
Cost. Credits are taken from your pool when the render is created. A render that fails is refunded automatically and the render object then carries a refund. If the pool is short, POST /renders returns 402 insufficient_credits.
The render object
{
"id": "0192f3c1-7d5e-7a3b-9c1d-2f4a6b8c0d1e",
"object": "render",
"order_number": 48213,
"livemode": true,
"template": "BasicEditorial",
"status": "succeeded",
"stage": null,
"credits_charged": 2,
"external_ref": "crm-listing-8841",
"metadata": { "crm_user_id": "u_193" },
"created_at": "2026-10-02T09:14:03.000Z",
"completed_at": "2026-10-02T09:19:47.000Z",
"output": {
"video_url": "https://...signed...",
"thumbnail_url": "https://...signed...",
"urls_expire_at": "2026-10-02T10:19:50.000Z",
"duration_seconds": 31.2,
"file_size_bytes": 38211904,
"width": 1080,
"height": 1920,
"share_url": null
},
"error": null,
"refund": null
}| Field | Meaning |
|---|---|
status | queued, processing, succeeded or failed. See status mapping. |
stage | While processing, the current step in lower case. Informational: new values can appear. |
credits_charged | Credits taken for this render. |
output | Only when succeeded. Signed video_url and thumbnail_url, urls_expire_at, duration_seconds, file_size_bytes, and width and height (1080 by 1920). share_url is filled only for partners without white-label. |
error | Only when failed: a code (listing_fetch_failed, media_invalid, render_failed or timeout) and a plain-language message. |
refund | Once credits have been returned: credits and refunded_at. |
completed_at | When the render succeeded or failed. Null while it is running. |
Status mapping
| status | stage | Meaning |
|---|---|---|
queued | null | Accepted, waiting to start. |
processing | preparing, generating_script, generating_clips, rendering, post_processing or a later value | In progress. Treat any unknown stage as processing. |
succeeded | null | The video is ready in output. |
failed | null | The render did not finish. Credits are refunded and refund is set once they land. |
The set of status values is stable. Only stage can gain values.
Webhooks
Register an https endpoint with POST /webhook_endpoints (or in the Partner dashboard) and we send events to it instead of you polling. The URL must use a public hostname. You can have up to 5 endpoints per mode, and each endpoint gets only events from its own mode: register with a test key for test events and a live key for live events.
| Event | Sent when | On by default |
|---|---|---|
render.succeeded | A render is delivered. data.object is the full render object with output links valid for 24 hours. | Yes |
render.failed | A render failed. Sent once, with the refund filled in when it has landed. | Yes |
render.processing | A render first leaves the queue. | No |
balance.low | Your pool falls to or below your low-balance threshold after a live render. data.object is the balance. | Yes |
ping | You call the test endpoint. | Always |
{
"id": "evt_0192f3c97d5e7a3b9c1d2f4a6b8c0d1e",
"object": "event",
"type": "render.succeeded",
"api_version": "2026-10-01",
"created_at": "2026-10-02T09:19:48.000Z",
"livemode": true,
"data": { "object": { "id": "0192f3c1-...", "object": "render", "status": "succeeded", "...": "the full render object" } }
}Each delivery is a POST with these headers:
EstateReels-Signature: t=1759396788,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
EstateReels-Event-Id: evt_0192f3c97d5e7a3b9c1d2f4a6b8c0d1e
EstateReels-Delivery-Attempt: 1
User-Agent: EstateReels-Webhooks/1Delivery and retries. Answer with any 2xx within 10 seconds to confirm. Redirects are not followed. Anything else, including a timeout, is retried after 1 minute, 10 minutes, 1 hour, 3 hours, 8 hours, 24 hours (measured from the first attempt), 7 attempts in total. Test-mode events get 3 attempts. A 410 Gone disables the endpoint immediately, and an endpoint that has failed continuously for 72 hours is disabled and we email you. Re-enable it from the dashboard or with POST /webhook_endpoints/{id} {"status":"enabled"}.
Delivery is at least once and order is not guaranteed. Store each EstateReels-Event-Id you have handled and ignore repeats. Events are kept for 30 days, so GET /events can fill any gap. Deliveries are sent about once a minute, so allow up to a minute for an event to arrive.
Rotating a secret. Secrets look like ersec_... and are shown once. After rotate_secret both the old and the new secret sign each delivery for 24 hours, so you can switch without dropping events. Pass expire_previous_after_seconds: 0 to retire the old one at once.
Verifying signatures
Every delivery is signed so you can be sure it came from us. The header is t=<unix seconds>,v1=<signature>, and during a rotation it carries two v1 values. Each signature is the hex HMAC-SHA256 of the string {t}.{raw request body} using your endpoint secret. This is the same construction Stripe uses.
express.raw; in Flask use request.get_data().Your check should: parse the header, reject if the timestamp is more than 300 seconds from your clock, compute the signature, and compare it to each v1 in constant time.
const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
// rawBody must be the exact bytes EstateReels sent (a Buffer or string), NOT
// the result of JSON.parse and JSON.stringify. header is the value of the
// EstateReels-Signature header. secret is your endpoint secret (ersec_...).
function verifyEstateReelsSignature(rawBody, header, secret) {
if (!header) return false;
let t = null;
const signatures = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t" && /^[0-9]+$/.test(value)) t = Number(value);
else if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + ".")
.update(rawBody)
.digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
return signatures.some((sig) => {
const sigBuf = Buffer.from(sig, "utf8");
return sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf);
});
}
module.exports = { verifyEstateReelsSignature };const express = require("express");
const { verifyEstateReelsSignature } = require("./verify");
const app = express();
const seen = new Set(); // use a database or Redis in production
// express.raw keeps the body as bytes. Do not put express.json() in front of this route.
app.post("/hooks/estatereels", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyEstateReelsSignature(
req.body,
req.get("EstateReels-Signature"),
process.env.ER_WEBHOOK_SECRET
);
if (!ok) return res.sendStatus(400);
const eventId = req.get("EstateReels-Event-Id");
if (seen.has(eventId)) return res.sendStatus(200); // already handled
seen.add(eventId);
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "render.succeeded") {
// copy event.data.object.output.video_url into your own storage
}
res.sendStatus(200);
});import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
def verify_estatereels_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""raw_body: the exact bytes received. header: EstateReels-Signature. secret: ersec_..."""
if not header:
return False
t = None
signatures = []
for part in header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t" and re.fullmatch(r"[0-9]+", value):
t = int(value)
elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if t is None or not signatures:
return False
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(sig, expected) for sig in signatures)from flask import Flask, request, abort
from verify import verify_estatereels_signature
import json, os
app = Flask(__name__)
seen = set() # use a database or Redis in production
@app.post("/hooks/estatereels")
def estatereels_hook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_estatereels_signature(
raw, request.headers.get("EstateReels-Signature", ""), os.environ["ER_WEBHOOK_SECRET"]
):
abort(400)
event_id = request.headers.get("EstateReels-Event-Id")
if event_id in seen:
return "", 200 # already handled
seen.add(event_id)
event = json.loads(raw)
if event["type"] == "render.succeeded":
pass # copy event["data"]["object"]["output"]["video_url"] into your storage
return "", 200Send a test event from the dashboard or with POST /webhook_endpoints/{id}/test to check your verifier end to end.
Errors
Failed requests return a JSON error and an HTTP status.
{
"error": {
"type": "invalid_request_error",
"code": "invalid_request",
"message": "Invalid value for listing.images: Array must contain at least 1 element(s)",
"param": "listing.images"
}
}type is one of invalid_request_error, authentication_error, rate_limit_error or api_error. param names the offending field when there is one.
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | The body, a query parameter or a header is missing or malformed. param names the field. |
| 400 | unsupported_power_up | The power-up key is unknown, or the template does not support it. Check supported_power_ups on GET /templates. |
| 400 | unsupported_listing_url | listing_url only accepts Property Finder links. Send the listing data in listing instead. |
| 400 | webhook_endpoint_limit | You already have 5 webhook endpoints in this mode. Delete one first. |
| 401 | invalid_api_key | The key is missing, malformed, revoked or unknown. |
| 402 | insufficient_credits | The pool cannot cover this render. Top up, then retry with the same Idempotency-Key. |
| 403 | partner_suspended | The partner account is not active, or does not include API access. |
| 404 | template_not_found | No template with that slug. See GET /templates. |
| 404 | render_not_found | No render with that id in this mode. |
| 404 | webhook_endpoint_not_found | No webhook endpoint with that id in this mode. |
| 404 | event_not_found | No event with that id in this mode, or it is older than 30 days. |
| 409 | idempotency_conflict | This Idempotency-Key was already used with a different request body. |
| 429 | rate_limited | Too many requests in the last minute. Wait for Retry-After seconds. |
| 429 | concurrency_limit | You already have the maximum number of renders in progress. Retry after Retry-After seconds. |
| 503 | service_unavailable | Temporarily unavailable on our side (for example, webhook endpoints cannot be created or rotated right now). Nothing was changed. Retry later. |
| 500 | api_error | Something went wrong on our side. Safe to retry, with the same Idempotency-Key for creates. Quote the Request-Id header if you contact us. |
Rate limits
Limits apply per partner account across all your keys. Read requests (every GET, and POST /render_quotes) are limited to 60 a minute. Write requests (POST /renders and creating, updating, rotating, testing or deleting webhook endpoints) are limited to 20 a minute. Responses include RateLimit-Limit and RateLimit-Remaining; an over-limit request returns 429 rate_limited with Retry-After in seconds.
Separately, only a limited number of your renders can be in progress at once (3 by default; ask us if you need more). A create over that limit returns 429 concurrency_limit with Retry-After: 30. Retry with the same Idempotency-Key.
Test mode
Keys starting er_test_ use the same endpoints and rules as live keys but return a sample video. Test renders never spend credits, so credits_charged_in_this_mode on a quote is 0. Test and live data are separate: renders, events and webhook endpoints created with one kind of key are not visible to the other. GET /balance reports your live pool balance with livemode: false. Test webhooks are sent only to endpoints registered with a test key.
Changelog
| Version | Change |
|---|---|
2026-10-01 | First public version: templates, render quotes, renders, balance, webhook endpoints with signed delivery and events. Every event carries the api_version it was created under. |
New fields, event types and templates are added without a new version. A change that could break a correct client gets a new dated version, announced here first.