Developer Page Specifications
API Contracts
GET listing/summary/details endpoints + POST subscription. Query params, response shapes, caching.
Complete
Three endpoints power the public Opportunity Radar surface: listing search, summary counters, and details fetch. One internal endpoint powers subscriptions. All responses are locale-aware via the Accept-Language header AND a fallback ?lang= query param (lang wins when both present). Responses are JSON, gzipped, cache-control honoured at the CDN.
1 · GET /api/opportunities — listing
- Query params
- q (string ≥2) · status (open|closing-soon|rolling|closed|all) · type (csv of slugs) · country (csv of ISO codes) · stage (csv) · sector (csv) · sort (most-relevant|deadline-soonest|newest|recently-verified|closing-soon) · page (int ≥1, default 1) · pageSize (int ≤100, default 24) · lang (en|ar).
- Response shape
- { items: OpportunityListItem[], totalCount: int, pageSize: int, page: int, filters: { applied, available }, counts: { open, closingSoon, rolling, closed, all } }.
- OpportunityListItem fields
- id, slug, title, shortDescription, status, type {slug, label}, country {code, name}, stage[], sectors[], organizer {id, name, logoUrl}, deadlineDate (ISO 8601 or null), isRolling, isSponsored, isVerified, officialUrl, publishedAt, lastVerifiedAt.
- Caching
- Cache-Control: public, max-age=120, s-maxage=300, stale-while-revalidate=600. Invalidate on Opportunity publish / status flip.
2 · GET /api/opportunities/summary — board counters
- No params (locale via header). Returns { open: int, closingSoon: int, rolling: int, tracked: int, latest: OpportunityListItem[] } where latest is the 5 most recent active opportunities.
- Cache-Control: public, max-age=60, s-maxage=120, stale-while-revalidate=300.
3 · GET /api/opportunities/{slug} — details
- Path params
- slug (string, required, kebab-case).
- Query params
- lang (en|ar, optional). Defaults to Accept-Language.
- Response shape
- { id, slug, title, shortDescription, fullDescription, programStructure?, whyItMatters?, founderProfile?, minimumRequirements?, applicationRequirements?, bestForStages?, status, type, country, multiCountries[], stages[], sectors[], organizer {full}, deadlineDate, isRolling, isSponsored, isVerified, officialUrl, valueSignal, urgency, timeRequired, benefits[], eligibilityCriteria[], applicationRequirements {struct}, timeline[], relatedOpportunities[], connectedSignals[], seo {metaTitle, metaDescription, ogImage, jsonLd}, publishedAt, lastVerifiedAt, verifiedBy {displayName} }.
- connectedSignals[] shape
- [{ sourceEntityType, sourceEntityId, sourceTitle, sourceCoverUrl, reason, sourceRoute, relationType, displayOrder }]. Capped at 8 server-side; client can request /api/opportunities/{slug}/connected?offset=8 for the full list.
- Caching
- Cache-Control: public, max-age=300, s-maxage=600, stale-while-revalidate=1800. Invalidate on Opportunity update / verify.
- Errors
- 404 when slug unknown. 410 Gone when IsActive=false (rare — soft-deletes preserve content but may return 410 if editorial decides to retire). Never 500 to client — log server-side, return 503 with retry hint.
4 · POST /api/subscriptions/opportunity-digest
- Body: { email, country?, stage?, consentPlatform (bool, must be true), consentPartners (bool, default false), source: 'listing' | 'details', opportunityIdContext? (uuid, only when source='details') }.
- Response: 201 { subscriptionId } on success. 422 with field-level errors on validation fail.
- Server-side: de-dupes by Email; existing subscriber gets preference fields merged. consentPartners=true triggers a separate sponsor-CRM webhook.
- No rate-limit headers exposed to client; honour 429 if it ever returns.
