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.

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" }
}
shell
# 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 pathWhat it does
GET /templatesTemplates your key can use, with credit cost, supported power-ups and whether a presenter is required.
POST /render_quotesSame body as create. Returns the credit cost and a breakdown without creating anything.
POST /rendersCreate 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 /rendersList renders, newest first. Filters: status, external_ref, created_after. Cursor pagination with starting_after and limit (1 to 100, default 10).
GET /balanceYour credit pool: credits, livemode and low_balance_threshold.
GET /webhook_endpointsList your webhook endpoints in this mode.
POST /webhook_endpointsRegister 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_secretIssue 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}/testQueue a signed ping event to that endpoint.
GET /eventsThe 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.

FieldNotes
templateRequired. A template slug from GET /templates.
listingtitle, 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_typeapartment, villa, townhouse, penthouse, studio, duplex, loft, office, retail, warehouse, land or other.
furnishingfurnished, semi_furnished or unfurnished.
audience_personainvestor, young_professional, family or generic (default generic). Changes the script's emphasis.
power_upsAny of enhance_photos, extra_photos, ai_motion, four_k that the template supports. Each has its own credit cost in GET /templates.
presenterRequired when the template has requires_presenter. Either {"mode":"prebuilt","prebuilt_key":"emirati-woman"} or {"mode":"random","gender":"female"}. Omit it for other templates.
brandingRequired. 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, metadataYour 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

render
{
  "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
}
FieldMeaning
statusqueued, processing, succeeded or failed. See status mapping.
stageWhile processing, the current step in lower case. Informational: new values can appear.
credits_chargedCredits taken for this render.
outputOnly 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.
errorOnly when failed: a code (listing_fetch_failed, media_invalid, render_failed or timeout) and a plain-language message.
refundOnce credits have been returned: credits and refunded_at.
completed_atWhen the render succeeded or failed. Null while it is running.

Status mapping

statusstageMeaning
queuednullAccepted, waiting to start.
processingpreparing, generating_script, generating_clips, rendering, post_processing or a later valueIn progress. Treat any unknown stage as processing.
succeedednullThe video is ready in output.
failednullThe 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.

EventSent whenOn by default
render.succeededA render is delivered. data.object is the full render object with output links valid for 24 hours.Yes
render.failedA render failed. Sent once, with the refund filled in when it has landed.Yes
render.processingA render first leaves the queue.No
balance.lowYour pool falls to or below your low-balance threshold after a live render. data.object is the balance.Yes
pingYou call the test endpoint.Always
event envelope
{
  "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:

headers
EstateReels-Signature: t=1759396788,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
EstateReels-Event-Id: evt_0192f3c97d5e7a3b9c1d2f4a6b8c0d1e
EstateReels-Delivery-Attempt: 1
User-Agent: EstateReels-Webhooks/1

Delivery 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.

Use the raw body. Verify the exact bytes you received. If your framework parses the JSON first and you re-serialise it, key order and whitespace change and every signature will fail. In Express use 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.

Node.js: verify.js
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 };
Node.js: Express route
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);
});
Python: verify.py
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)
Python: Flask route
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 "", 200

Send 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
{
  "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.

StatuscodeMeaning
400invalid_requestThe body, a query parameter or a header is missing or malformed. param names the field.
400unsupported_power_upThe power-up key is unknown, or the template does not support it. Check supported_power_ups on GET /templates.
400unsupported_listing_urllisting_url only accepts Property Finder links. Send the listing data in listing instead.
400webhook_endpoint_limitYou already have 5 webhook endpoints in this mode. Delete one first.
401invalid_api_keyThe key is missing, malformed, revoked or unknown.
402insufficient_creditsThe pool cannot cover this render. Top up, then retry with the same Idempotency-Key.
403partner_suspendedThe partner account is not active, or does not include API access.
404template_not_foundNo template with that slug. See GET /templates.
404render_not_foundNo render with that id in this mode.
404webhook_endpoint_not_foundNo webhook endpoint with that id in this mode.
404event_not_foundNo event with that id in this mode, or it is older than 30 days.
409idempotency_conflictThis Idempotency-Key was already used with a different request body.
429rate_limitedToo many requests in the last minute. Wait for Retry-After seconds.
429concurrency_limitYou already have the maximum number of renders in progress. Retry after Retry-After seconds.
503service_unavailableTemporarily unavailable on our side (for example, webhook endpoints cannot be created or rotated right now). Nothing was changed. Retry later.
500api_errorSomething 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

VersionChange
2026-10-01First 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.