Core concepts

Finding a lease

How the portfolio, property, unit and lease records fit together, how to walk from one to the next, and what to read once you have the lease you wanted.

Almost everything in MagicDoor hangs off a lease, and almost every integration starts by finding one. This page walks the whole path with real calls, then shows what to read from a lease once you have it.

portfolio            a book of business — one owner group, one set of books
 └── property        a building or a house
      └── unit       a rentable space; a single-family house still has one
           └── lease an agreement over that unit for a period of time

Each record carries the ids of everything above it, which is what makes the shortcut in the next section possible. A lease knows its unitId, propertyId and portfolioId — you never have to walk back up.

A unit can have more than one lease

Past leases stay on the unit, and a lease signed for a future move-in exists alongside the current one. "The lease for this unit" is a question about dates, not a lookup — filter by ended and the lease-date range rather than assuming a unit has exactly one.

If you already know which portfolio, property or unit you care about, do not walk the tree. GET /company-app/leases filters on any level directly:

curl --request GET \
  --url 'https://api.portal.magicdoor.com/company-app/leases?portfolioId=1830472847398404096&ended=false' \
  --header "Authorization: Bearer $MAGICDOOR_TOKEN"

There are plural forms too — portfolioIds, propertyIds, unitIds and tenantIds — so one call can cover several at once. See List leases for the full filter set, which also covers balances, expiry dates, eviction and month-to-month.

When you are exploring rather than looking something up, walk down a level at a time.

  1. List the portfolios

    This one is not paginated — it returns a plain JSON array, and each portfolio already has its properties expanded. For a small book of business this single call may be all you need.

    curl --request GET \
      --url 'https://api.portal.magicdoor.com/company-app/portfolios?active=true' \
      --header "Authorization: Bearer $MAGICDOOR_TOKEN"
    200 OK (trimmed)
    [
      {
        "id": "1830472847398404096",
        "name": "Riverside Holdings",
        "active": true,
        "propertyCount": 2,
        "activePropertyCount": 2,
        "properties": [
          { "id": "1830472847398404097", "displayName": "Evergreen Terrace" }
        ]
      }
    ]
  2. List that portfolio's properties

    If you need the full property record — unit counts, rent roll, balances — rather than the summary embedded above, ask for it directly. This endpoint is paginated.

    curl --request GET \
      --url 'https://api.portal.magicdoor.com/company-app/properties?portfolioId=1830472847398404096&page=1&pageSize=50' \
      --header "Authorization: Bearer $MAGICDOOR_TOKEN"
  3. List the property's units

    Filter by propertyId. A single-family house has exactly one unit — there is no special case for it in the data model.

    curl --request GET \
      --url 'https://api.portal.magicdoor.com/company-app/units?propertyId=1830472847398404097&page=1&pageSize=50' \
      --header "Authorization: Bearer $MAGICDOOR_TOKEN"
  4. List the unit's leases

    Add ended=false for the ones still running. Leave it off to see the unit's whole history.

    curl --request GET \
      --url 'https://api.portal.magicdoor.com/company-app/leases?unitId=1832019384756201984&ended=false' \
      --header "Authorization: Bearer $MAGICDOOR_TOKEN"
List leases already embeds what you walked for

GET /company-app/leases returns each lease with its unit, property and tenants filled in. So the walk above is worth doing when you want the intermediate records themselves — a property's rent roll, a unit's details — and worth skipping when you only want leases.

With a lease id, Get a lease returns the record itself:

GET /company-app/leases/1830472847398404096
{
  "id": "1830472847398404096",
  "portfolioId": "1827741029384756224",
  "propertyId": "1829933847562019840",
  "unitId": "1832019384756201984",
  "start": "2026-03-01",
  "end": "2027-02-28",
  "ended": false,
  "monthToMonth": false,
  "eviction": false,
  "draft": false,
  "currentRent": 1850.00,
  "balance": 1850.00,
  "postedBalance": 1850.00,
  "securityDepositRequested": 1850.00,
  "securityDepositPaid": 1850.00,
  "securityDepositDue": 0.00,
  "lastPaymentDate": "2026-08-01",
  "earliestUnpaidBillDueDate": "2026-09-01",
  "pastDueDays": 0
}
FieldMeans
draftNot a real agreement yet. It is being prepared and has not been activated
endedDeliberately terminated. A lease past its end date is not automatically ended
monthToMonthContinues past end a month at a time rather than expiring
An expired lease is a normal, ongoing state

If end is in the past and ended is false, the lease is still live as far as MagicDoor is concerned — the tenant is still there and rent is still owed. Nothing in the system turns expiry into termination on its own.

So do not treat end < today as "finished". To find leases that need attention, use List expiring leases rather than comparing dates yourself.

balance is everything owed. postedBalance counts only charges that have actually been posted to the ledger — future-dated charges are excluded. When you want "what does this tenant owe right now", postedBalance is usually the honest answer.

pastDueDays is days since the oldest unpaid bill came due, and is absent when nothing is overdue.

The lease record is a summary. Each of these reads one part of it in full, all keyed on the same leaseId.

Tenants come with the lease

There is no "list this lease's tenants" endpoint, because you already have them — both GET /company-app/leases and GET /company-app/leases/{leaseId} return the lease's tenants inline. The lease tenants endpoints are for changing who is on the lease — moving someone in or out — not for reading them.

Every active lease in one portfolio, with what each tenant currently owes:

const PORTAL = "https://api.portal.magicdoor.com";

async function arrearsFor(portfolioId: string, token: string) {
  const rows: { lease: string; unit: string; owed: number }[] = [];
  let page = 1;
  let seen = 0;
  let total = Infinity;

  while (seen < total) {
    const url = new URL(`${PORTAL}/company-app/leases`);
    url.searchParams.set("portfolioId", portfolioId);
    url.searchParams.set("ended", "false");
    url.searchParams.set("page", String(page));
    url.searchParams.set("pageSize", "100");

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!response.ok) throw new Error(`Listing leases failed: ${response.status}`);

    const body = await response.json();
    total = body.totalCount;
    if (body.items.length === 0) break;

    for (const lease of body.items) {
      // postedBalance excludes charges dated in the future.
      if (lease.postedBalance > 0) {
        rows.push({
          lease: lease.id,          // keep ids as strings
          unit: lease.unit?.name ?? lease.unitId,
          owed: lease.postedBalance,
        });
      }
    }

    seen += body.items.length;
    page += 1;
  }

  return rows;
}

Note the response field is currentPage, not page — see Pagination. The loop above counts records instead, which works either way.