Onboard a portfolio in bulk
Create properties, buildings, floor plans, units, media, and content for a new portfolio without hand entry.
Who this is for: client internal teams and implementation partners standing up a new portfolio, and vendors migrating a client from another platform.
What you'll build: a resumable script that creates records in dependency order and can be re-run safely after a partial failure.
Before you start
Decide whether a PMS connection will own this data. If the properties will sync from a PMS, do not bulk-create units: the connection will import them, and your hand-created units will collide with or be overwritten by the import. In that case, create properties, attach the connection, run it, and let the sync populate inventory. See Manage PMS & CRM connections.
Bulk creation is the right path when there is no PMS connection, or for the data a PMS does not own: marketing content, media, amenities, FAQs, galleries, neighborhood places.
Set reference_id wherever it is accepted. Properties, buildings, floor plans, units, amenities and media take a reference_id: your id for the record in the source system. It is a reliable handle back to your source records, and on media it is also a dedup key. Other resources do not have one, so your ledger (below) is the handle for those.
Dependency order
Records must be created in this order; each level references the one above:
1. Properties
2. Buildings (property_id)
3. Floor plans (property_id)
4. Units (property_id, building_id?, floor_plan_id?)
5. Amenities & fees (account-level, linked with property_ids / floor_plan_ids / unit_ids)
6. Content (content blocks, FAQs, galleries, announcements, neighborhood places)
7. Media (requires the parent to exist)Media is last because an import attaches to a parent at creation: you cannot stage assets ahead of their parents.
Make it resumable
The single most important design decision. A 3,000-unit onboarding will fail partway: a network blip, a 429, a bad row in the source file. Re-running from scratch either duplicates everything or requires manual cleanup.
Keep a local ledger keyed by your source id:
const ACCEPTS_REFERENCE_ID = new Set(["properties", "buildings", "floor-plans", "units", "amenities", "media"]);
// ledger: sourceId -> { resiId, resource, createdAt }
async function createOnce(resource, sourceId, payload, idOf = (data) => data.id) {
const existing = ledger.get(sourceId);
if (existing) return existing.resiId;
const body = ACCEPTS_REFERENCE_ID.has(resource) ? { ...payload, reference_id: sourceId } : payload;
const res = await request(`https://v2.getresi.com/api/v2/${resource}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`${resource} ${sourceId}: ${res.status} ${await res.text()}`);
const { data } = await res.json();
const resiId = idOf(data);
ledger.set(sourceId, { resiId, resource, createdAt: new Date().toISOString() });
await persistLedger(); // flush after every write, not at the end
return resiId;
}This ledger is what prevents duplicates on a retried run: creates for properties, buildings, floor plans and units are not deduplicated by Resi, so a repeated POST makes a second record. Flush the ledger to disk after each write; an in-memory ledger lost to a crash defeats the point.
Creating the hierarchy
Use POST /api/v2/properties, POST /api/v2/buildings, POST /api/v2/floor-plans and POST /api/v2/units.
for (const src of sourceProperties) {
const propertyId = await createOnce("properties", `prop:${src.id}`, {
name: src.name,
description: src.description,
is_enabled: false, // stage disabled; enable after review
tags: src.tags,
address: {
street: src.street, city: src.city, state: src.state,
zipcode: src.zip, country: "US",
latitude: src.lat, longitude: src.lng,
timezone: "America/Chicago",
},
phone_numbers: [{ type: "leasing_office", title: "Leasing Office", number: src.phone }],
emails: [{ type: "leasing_office", title: "Leasing Office", email: src.email }],
});
for (const b of src.buildings) {
await createOnce("buildings", `bldg:${b.id}`, {
property_id: propertyId, name: b.name, number: b.number, code: b.code,
});
}
for (const fp of src.floorPlans) {
await createOnce("floor-plans", `fp:${fp.id}`, {
property_id: propertyId,
name: fp.name, code: fp.code,
min_bed: fp.beds, max_bed: fp.beds,
min_bath: fp.baths, max_bath: fp.baths,
min_sqft: fp.sqft, max_sqft: fp.sqft,
});
}
}Send the address with coordinates. A new address needs street, city, state, zipcode and country. Resi does not geocode addresses sent through the API, so set latitude and longitude yourself or the property will have no map pin.
Create properties with is_enabled: false. Stage the whole portfolio, review it in the Resi app, then flip enabled in a second pass with PATCH /api/v2/properties/{property}. A half-loaded portfolio that is live is worse than one that is not yet visible.
Rate budget and pacing
The V2 limit is 120 requests/minute per user. A portfolio of 20 properties × 150 units is about 3,200 writes: roughly 27 minutes at full throttle, longer with headroom.
- Concurrency of 2–4. More produces
429s that cost more time than they save. - Honor
Retry-Afterand back off; see Errors, rate limits & reliability. - Run bulk jobs off-hours, and ask for a token created by a different user than the one your live integrations use. The budget is per user, so a bulk load can otherwise throttle a running lead flow.
- Media imports have their own limits.
POST /api/v2/mediaallows 60 requests a minute, and each one downloads a file while the request runs.POST /api/v2/media/batchtakes up to 100 items per request, 10 requests a minute. Run media as a separate, slower pass.
Media pass
Once parents exist:
for (const asset of sourceMedia) {
const parentId = ledger.get(asset.parentSourceId)?.resiId;
if (!parentId) continue; // parent failed; fix and re-run
await createOnce("media", `media:${asset.id}`, {
kind: "file",
url: asset.publicUrl, // must be reachable *now*
attachable_type: asset.parentType, // property | unit | floor_plan | building | …
attachable_id: parentId,
media_type: asset.slot, // property_image | image | floor_plan_2d | pdf | …
caption: asset.caption,
alt_text: asset.altText,
sort_order: asset.order,
tags: ["onboarding:2026-09"],
}, (data) => data.media.id);
}- Source URLs must be publicly reachable at import time. Resi downloads the file while the request runs. An item whose URL cannot be fetched, such as a signed URL that expired mid-run, fails with
422; refresh the URL and send that item again. - Imports dedupe. A file matches an existing asset in the account on its source
urlfirst, then itsreference_id. A match re-attaches the existing asset instead of downloading again, and leaves its caption, alt text and tags as they were. That makes retries safe, but it will not update a caption: usePATCH /api/v2/media/{media}for that. - Batches can stop early. A batch gets about 40 seconds of download time. Items it did not start come back with status
503; send only those again. Files in one batch may come from at most 25 different hosts. - Tag every import with a batch tag.
GET /api/v2/media?tag=…is how you find your work later. - There is no delete. The API cannot remove an imported asset. Validate your source list before the media pass, not after.
Content
Content blocks, FAQs, galleries and announcements can be global (is_global: true, shared across the account) or linked to specific properties with property_ids. Create shared content once as global rather than duplicating it per property: it is a large maintenance saving for the client later. Neighborhood places always belong to one property and one neighborhood category.
Groups are set up in the Resi app; they are not part of the documented V2 API.
Verify before enabling
Re-read what you created and check counts against your source before flipping anything on:
const units = await collect("/units", { property_id: propertyId });
assert(units.length === src.units.length, `${src.name}: ${units.length} vs ${src.units.length}`);collect() is the paginating helper from Build an ILS / syndication feed; it re-sends your parameters with every page. Then walk a sample of records in the Resi app. Spot-check media rendering, floor plan associations, and pricing display before enabling the portfolio.
If something goes wrong
- Properties can be deleted:
DELETE /api/v2/properties/{property}returns202and queues a full teardown. The property remains visible until a worker finishes; pollGET /properties/{id}for a404if you need to confirm. - Units, floor plans, buildings, amenities, fees and content support
DELETE, which returns204. - Media does not. Imported assets can only be removed in the Resi app.
Checklist
- Confirmed whether a PMS connection will own inventory
- Ledger keyed by source id, flushed after every write
-
reference_idset on every record that accepts one - Dependency order respected
- Addresses sent with coordinates
- Properties staged with
is_enabled: false - Concurrency ≤ 4,
Retry-Afterhonored - Bulk token issued to a different user than live integrations
- Media as a separate pass, source URLs verified reachable
- Batch tag on every imported asset
- Counts verified and a sample reviewed before enabling
Last updated on