Wigmore Hall Concert Search

Site wigmore-hall.org.ukTask wigmore-hall-imvxspVersion v1Updated Jul 13, 2026Category music

Search Wigmore Hall (London) concerts by composer, performer, or work — returns title, date/time, programme summary, price band, and canonical concert URL via the venue's public unauthenticated JSON API. This skill was captured from a live agent session on wigmore-hall.org.uk and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

Purpose

Return Wigmore Hall (London) concerts whose title, performer line-up, or programme contains a given search term — composer surname (e.g. Beethoven), performer name (e.g. Igor Levit), work title (e.g. Goldberg Variations), or work catalogue number (e.g. BWV1004, Op. 109). Each hit carries the performance title, ISO date/time, programme blurb (composers joined with commas — e.g. "Brahms, Bach, Beethoven and more"), ticket-price summary, canonical concert URL, and Tessitura booking ID. Read-only — never books, reserves, or holds a slot.

When to Use

  • "Which upcoming Wigmore concerts feature Schubert?"
  • "Is there a Goldberg Variations recital at Wigmore in the next 12 months?"
  • Aggregator pulling weekly listings of Beethoven / Schubert / Bach concerts for a music-magazine newsletter.
  • Discovering whether a specific performer (e.g. Yunchan Lim, Lise Davidsen) has any forthcoming Wigmore dates.
  • Historical look-up across the archive (e.g. "how many Mahler concerts did Wigmore host between 2020 and 2024?").

Workflow

Wigmore Hall publishes a clean, unauthenticated, anti-bot-free JSON API that powers the public /search?term=… page. The API endpoint is GET https://www.wigmore-hall.org.uk/api/v1/search?term={QUERY} — single-segment search across performance titles, performer line-ups, programme subtitles, and per-work composer/title/opus metadata. Use it directly with a plain HTTP fetch (no residential proxy, no stealth, no cookies, no session warmup). The browser path is a fallback only — the /search page is a React SPA that issues the same request, so going through Chrome adds latency without buying anything.

1. Build the request URL

GET https://www.wigmore-hall.org.uk/api/v1/search
    ?term={url-encoded-query}            # required — full-text match
    [&page={1-indexed page}]              # default 1; page size is fixed at 12
    [&startDate=YYYY-MM-DDTHH:mm:ss.000Z] # default today (forthcoming only)
    [&endDate=YYYY-MM-DDTHH:mm:ss.000Z]   # default unbounded (all future)
Accept: application/json

No Referer, Origin, Cookie, or User-Agent requirements — verified with a direct HTTP fetch (no proxies) returning 200 in ~250ms.

2. Choose the date window

WantstartDateendDate
Forthcoming only (default)omit (server defaults to today)omit
Forthcoming + archived (full catalogue)1900-01-01T00:00:00.000Zomit
Archived only1900-01-01T00:00:00.000Ztoday (YYYY-MM-DD)
Specific windowwindow start (ISO Zulu)window end (ISO Zulu)

Empirical totals (term=Mozart, captured 2026-05-25): forthcoming = 40, all-time = 838, archived alone = 798. Verified by toggling startDate.

3. Issue the request

curl -sS "https://www.wigmore-hall.org.uk/api/v1/search?term=Schubert" \
  | jq '{ total: .totalItems, pages: .totalPages, first: .items[0].node | {title, url, date, subtitleText} }'

The curl above is canonical — this endpoint is public and needs no browser. Only under restricted egress, route the same request through browserless_function, which runs in a browser page context: navigate to the origin first, then fetch same-origin from inside page.evaluate (a bare fetch has no network egress until the page has navigated).

// browserless_function — same-origin fetch of the public JSON API
export default async function ({ page }) {
  await page.goto("https://www.wigmore-hall.org.uk/", {
    waitUntil: "load",
    timeout: 45000,
  });
  const data = await page.evaluate(async () => {
    const r = await fetch("/api/v1/search?term=Schubert", {
      headers: { Accept: "application/json" },
    });
    return r.json();
  });
  return {
    data: {
      total: data.totalItems,
      pages: data.totalPages,
      first: data.items[0]?.node
        ? (({ title, url, date, subtitleText }) => ({
            title,
            url,
            date,
            subtitleText,
          }))(data.items[0].node)
        : null,
    },
    type: "application/json",
  };
}

Response is a single JSON object: { "items": [...], "totalItems": N, "totalPages": ceil(N/12) }. Page size is hard-coded to 12 server-side — pageSize, perPage, and limit are silently ignored.

4. Decode each items[i].node

Each node has __typename: "Performance" and these fields you'll actually use:

  • title — performer line-up as one string ("Asmik Grigorian soprano; Lukas Geniušas piano").
  • titleOverrideText — same but with HTML <sub> tags wrapping the instrument labels; strip if you want plain text.
  • subtitleText — programme summary: composer surnames joined with commas plus "and more" if truncated ("Bach, Beethoven, Haydn, Mozart and Schubert"). Treat this as the composer-set summary, NOT a full work list.
  • url — canonical concert path /whats-on/{YYYYMMDDhhmm}. Prefix with https://www.wigmore-hall.org.uk for the absolute URL.
  • date — ISO 8601 with London offset (e.g. 2026-06-04T19:30:00+01:00).
  • groupDateYYYY-MM-DD (no time) for date-grouped UIs.
  • tessituraId — the box-office system's integer ID (string-encoded, e.g. "61088"). Useful only if you also call Tessitura.
  • pricesText — HTML-encoded price summary with <br/><sub>+£4 booking fee per transaction</sub> suffix; strip tags or pass through as-is.
  • id — opaque base64 relay node ID (e.g. UGVyZm9ybWFuY2U6Mjc2Njc=); decodes to Performance:{number} but the number is not the same as tessituraId — don't try to derive one from the other.
  • isPriorityBooking — boolean, true when the concert is currently in a Friends/Members priority-booking window.
  • listingImage / listingImageAspect — image URL bundles (multiple breakpoints); use listingImage.src (400px-wide).

5. Paginate

totalPages = ceil(totalItems / 12). Fetch ?term=…&page=2, …&page=3, … until page > totalPages (which still returns 200 with "items": []). One request per page; ~250ms each from the sandbox. No throttling observed at 5 req/s sustained.

6. Resolve full programme (optional, per-concert)

The search endpoint only exposes a composer-set blurb. If you need the actual list of works (e.g. "Piano Sonata No. 30 in E Op. 109") or the per-work composer attribution, follow url to the SSR concert page https://www.wigmore-hall.org.uk/whats-on/{YYYYMMDDhhmm} and extract from the rendered HTML (look for the #### Programme heading; each composer line is [Composer Name](/artists/{slug}) followed by indented work entries). Pages are server-rendered, no JS needed — a direct HTTP fetch returns the full markup. Don't try /api/v1/performances/{id} — it 404s (no per-concert JSON endpoint exists).

Browser fallback

Used only when the API namespace is unreachable (unobserved in any of 2026-05-25 testing). Drive one ephemeral browserless_agent call — no proxy or stealth needed (this endpoint has no anti-bot) — with the whole flow in a single commands array so the page context persists: goto https://www.wigmore-hall.org.uk/search?term={QUERY} (waitUntil: "load"), then a waitForTimeout of 3000 ms (the listing widget hydrates ~1–2 s after DOMContentLoaded), then text on the body selector. The page renders results in chronological calendar groupings (### {Weekday DD Mon YYYY} headings). For archived results, click the ARCHIVED EVENTS ({N}) tab (confirm the selector via snapshot if it misses). Don't click BOOK NOW, Book now, or any per-concert price button — those redirect to the Tessitura booking flow. Read-only is the rule.

{
  "commands": [
    {
      "method": "goto",
      "params": {
        "url": "https://www.wigmore-hall.org.uk/search?term=Schubert",
        "waitUntil": "load",
        "timeout": 45000
      }
    },
    { "method": "waitForTimeout", "params": { "time": 3000 } },
    { "method": "text", "params": { "selector": "body" } }
  ]
}

Site-Specific Gotchas

  • q= alone returns zero results. Earlier iterations of the search page used ?q={query} — the server now requires ?term={query}. URLs with only q= (no term=) return {"items":[],"totalItems":0,"totalPages":0} from the API and a blank-result page from the SPA. Always send term=, not q=. Sending both q= and term= is harmless — server only reads term=.
  • Default startDate is today (server-side, not the URL). Hitting /api/v1/search?term=Mozart with no date params silently scopes the result set to forthcoming events only — even though the URL contains no date parameter. To get archived events you MUST pass startDate=1900-01-01T00:00:00.000Z (or any sufficiently-old ISO Zulu timestamp). Discovered when the same query returned 40 vs. 838 items depending on whether startDate was set.
  • Tab-switch on the /search page doesn't change the API call. The five tabs (FORTHCOMING EVENTS, ARCHIVED EVENTS, WATCH, SERIES, PAGES) and their counts are computed client-side from a single super-set response — switching tabs in the UI doesn't trigger a new request. The API endpoint always returns __typename: "Performance" nodes; the Watch / Series / Pages tabs come from a different SSR path and are out of scope for this concert-search skill.
  • contentTypes=Watch|Series|Page returns HTTP 500. Don't try to filter content types via the API; the server's GraphQL resolver throws on those values. contentTypes= (empty) is the only value the server accepts; non-empty PascalCase values 500. The other quasi-filter params (watchPageOnly, learningPageOnly, forthcomingPerformancesOnly) are silently accepted but have NO observable effect on the result set (verified by comparing totals across all four variants — all returned 40 for term=Mozart).
  • Page size is fixed at 12, server-side. pageSize, perPage, limit, and count are all silently ignored — every page returns 12 items max. For large result sets (e.g. Op. 109 returns 64 across 6 pages including archive) you'll need to paginate.
  • startDate overrides IP-geolocation? No — there's no geo gating on this endpoint at all. Same response regardless of source IP. No residential proxy needed.
  • subtitleText is a teaser, not the full programme. It's truncated to leading composers + "and more" (e.g. BACH, BEETHOVEN, HAYDN, MOZART AND SCHUBERT for a 5-composer concert; longer programmes get e.g. BEETHOVEN, CHOPIN, GEORGE XIAOYUAN FU, MESSIAEN AND R SCHUMANN). Don't parse it as a complete composer list. For full programmes, fetch the concert detail page (step 6).
  • Search hits include performers AND composers AND work titles AND opus numbers. It's a single unified full-text index. term=Levit (4 hits) finds concerts where Igor Levit performs; term=Beethoven (50+ hits) finds concerts that include any Beethoven work; term=Goldberg%20Variations finds the four specific concerts featuring that work. No way to disambiguate composer-vs-performer in the query (e.g. for term=Mahler you'll get both performer "Gustav Mahler — not applicable" and composer programmes). When the user supplies a name that is BOTH a composer and a performer (rare but possible with surnames like Mendelssohn), inspect each result's subtitleText (composers appear there) vs title (performers appear there) to classify.
  • Diacritics matter and must be URL-encoded. term=Erlk%C3%B6nig returns 4 hits; term=Erlkonig (no umlaut) returns 0. The index does not fold diacritics. Similarly Schubert → Schubert, Dvořák → Dvo%C5%99%C3%A1k. When the user types ASCII-only, fall back to a substring (e.g. term=Erlk matches), but you'll get false positives.
  • url is /whats-on/{YYYYMMDDhhmm}, not /concerts/{id}. The URL slug is derived from the concert start time (Europe/London, 24h). Two concerts on the same day are differentiated by their hour (e.g. /whats-on/202605251300 lunchtime + /whats-on/202605251930 evening). Use the url field verbatim — don't try to construct it from tessituraId or id.
  • tessituraIdid. The base64 id field (e.g. UGVyZm9ybWFuY2U6Mjc2Njc=Performance:27667) is the GraphQL Relay node ID; tessituraId (e.g. "61088") is the box-office system ID. Different number spaces — don't map one to the other.
  • No per-concert API endpoint. /api/v1/performances/{id} and /api/v1/performances/{tessituraId} both 404. To get full programme detail, you must scrape the SSR /whats-on/{slug} HTML.
  • Booking-flow URLs end in /booking/{tessituraId} (e.g. /booking/61095). Do not request these — they redirect into the Tessitura booking funnel and may set session/cart state. The search results include them in the "BOOK NOW" anchors; ignore them.
  • The /artists/{slug} SSR pages are a viable alternative for exact-composer / exact-performer lookups. Wigmore canonicalises every composer and performer to /artists/{slugified-name} (e.g. /artists/ludwig-van-beethoven, /artists/igor-levit). The page is server-rendered and lists concerts grouped by date with tabs labelled Events (N) | Archived Events (N) | Listen (N) | Watch (N) — the Events count matches the search-API forthcoming total for that name. Useful when you have the canonical slug and want to skip a possibly-ambiguous full-text query. The page only ships the first ~12 entries in the initial SSR; further entries load via JS scrolling — not currently exposed as a JSON endpoint, so paginate via the search API instead.
  • Cache TTL is 120 s (Cache-Control: max-age=120, must-revalidate, public). Same query within 2 minutes will return a cached response — fine for repeat reads, but if you're poll-monitoring for new on-sale dates, expect ≤2-minute staleness.

Expected Output

Three distinct outcome shapes — emit the one that matches what the search returned.

// Hit — one or more matching concerts
{
  "success": true,
  "query": "Schubert",
  "scope": "forthcoming",
  "total_results": 47,
  "concerts": [
    {
      "title": "Yunchan Lim piano",
      "subtitle": "Schubert and Skryabin",
      "date_iso": "2026-05-29T13:00:00+01:00",
      "date_local": "Fri 29 May 2026 — 1.00pm",
      "url": "https://www.wigmore-hall.org.uk/whats-on/202605291300",
      "tessitura_id": "61075",
      "node_id": "UGVyZm9ybWFuY2U6Mjc2NjA=",
      "prices_text": "£60 £53 £43 £33 £18",
      "is_priority_booking": false,
      "image_url": "https://admin.wigmore-hall.org.uk/images/XlN2nz5b1fOA2qOr-TsWvpsmvMY=/5972/width-400/..."
    }
  ]
}

// No hits
{
  "success": true,
  "query": "ASCIIComposerWhoDoesntExist",
  "scope": "forthcoming",
  "total_results": 0,
  "concerts": []
}

// Likely-misspelled / diacritic miss (heuristic)
{
  "success": false,
  "reason": "no_results_possible_diacritic_mismatch",
  "query": "Erlkonig",
  "hint": "Retry with diacritics URL-encoded — e.g. Erlk%C3%B6nig (Erlkönig). The search index does not fold diacritics.",
  "concerts": []
}

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=wigmore-hall.org.uk&task=wigmore-hall-imvxsp