You already run Cypress for end-to-end tests. Now you want to hit an API directly, check the status code, and assert on the JSON body without clicking through the UI. Cypress can do that. The cy.request() command sends real HTTP requests from Node, outside the browser, so you can test an endpoint on its own or set up state before a UI test runs.
This guide shows you how to test APIs with Cypress: the cy.request() basics, asserting on responses, chaining requests to reuse an auth token, and stubbing browser traffic with cy.intercept(). You’ll also see where Cypress-for-API is a good fit and where an API-native tool fits better.
Why test APIs with Cypress
Most Cypress suites drive a browser. But a lot of what you verify in the UI is the API underneath: does /login return a token, does /users return the right shape, does a POST create the record you expect.
Testing those endpoints directly has two payoffs. First, API checks run faster than UI flows because there’s no rendering, no waiting for elements. Second, you can use cy.request() to set up test data. Instead of clicking through a signup form to create a user, you send one POST and move on to the assertion you actually care about.
If Cypress is already in your stack, adding API tests means no new tool to install. You write them in the same spec files, with the same expect assertions, in the same CI run.
cy.request() basics
cy.request() makes an HTTP request and yields the response. It runs from the Cypress Node process, not the browser, so it isn’t subject to CORS or same-origin rules.
The command accepts several forms:
cy.request(url)
cy.request(url, body)
cy.request(method, url)
cy.request(method, url, body)
cy.request(options)
The simplest call passes a URL. With no method, Cypress defaults to GET:
cy.request('https://jsonplaceholder.typicode.com/users')
For anything beyond a basic GET, pass an options object. This gives you full control over method, body, and headers:
cy.request({
method: 'POST',
url: 'https://jsonplaceholder.typicode.com/posts',
headers: {
'Content-Type': 'application/json'
},
body: {
title: 'API test',
body: 'created from Cypress',
userId: 1
}
})
Useful options on that object:
method: the HTTP verb (GET,POST,PUT,PATCH,DELETE, and others). Defaults toGET.url: the endpoint. Required.body: the request payload. Cypress serializes objects to JSON.headers: an object of request headers.auth: credentials for HTTP Basic Authentication.qs: query string parameters as an object.failOnStatusCode: set tofalsewhen you want to assert on a 4xx or 5xx response instead of failing the test.
That last option matters for negative tests. By default cy.request() fails the test on any non-2xx or non-3xx status. If you’re checking that a bad request returns 400, you have to opt out:
cy.request({
method: 'GET',
url: 'https://jsonplaceholder.typicode.com/users/99999',
failOnStatusCode: false
}).then((response) => {
expect(response.status).to.eq(404)
})
Asserting on status and body
cy.request() yields a response object. Chain .then() to read it. The response has four properties you’ll use most:
status: the HTTP status code.body: the response payload. Cypress parses it to a JavaScript object when theContent-Typeends injson.headers: the response headers.duration: how long the request took, in milliseconds.
Here’s a full test that checks the status, the body shape, and a specific field:
describe('Users API', () => {
it('returns a list of users', () => {
cy.request('https://jsonplaceholder.typicode.com/users').then((response) => {
expect(response.status).to.eq(200)
expect(response.body).to.be.an('array')
expect(response.body).to.have.length(10)
expect(response.body[0]).to.have.property('email')
})
})
})
Because response.body is already a parsed object, you assert on it with standard Chai. You can check the type, the length, nested properties, or exact values. This is the same assertion syntax you use for UI tests, so there’s nothing new to learn.
You can also assert on response headers, which is handy for checking content type or caching:
cy.request('https://jsonplaceholder.typicode.com/users').then((response) => {
expect(response.headers['content-type']).to.include('application/json')
})
For a deeper look at structuring these checks, see our guide on API assertions and the broader API testing best practices.
Chaining requests and reusing an auth token
Real APIs need authentication. The common pattern is to log in once, grab the token, then send it on every request that follows. cy.request() chains cleanly for this.
Send the login request, read the token off the response body, then use it in the next call:
describe('Authenticated API flow', () => {
it('logs in and fetches a protected resource', () => {
cy.request({
method: 'POST',
url: 'https://api.example.com/login',
body: {
email: 'user@example.com',
password: 'secret'
}
}).then((loginResponse) => {
expect(loginResponse.status).to.eq(200)
const token = loginResponse.body.token
cy.request({
method: 'GET',
url: 'https://api.example.com/profile',
headers: {
Authorization: `Bearer ${token}`
}
}).then((profileResponse) => {
expect(profileResponse.status).to.eq(200)
expect(profileResponse.body).to.have.property('email', 'user@example.com')
})
})
})
})
If several tests need the same token, move the login into a beforeEach and store the token with Cypress.env() or a wrapped alias so each test picks it up. That keeps the auth setup in one place instead of repeating it in every spec.
This login-then-use pattern is the same shape you’d write in any API integration testing flow, where one call feeds data into the next.
cy.intercept() for stubbing
cy.request() hits the real API. Sometimes you want the opposite: intercept the requests your front-end app makes and return a fake response. That’s cy.intercept().
There’s an important distinction here. cy.intercept() only intercepts requests made by your front-end application in the browser. It does not intercept cy.request(), because cy.request() bypasses the browser entirely. Use cy.intercept() when you’re testing how your UI reacts to a given API response, not when you’re testing the API itself.
You can spy on a request, stub it with a static response, or wait for it. To stub, pass a response object:
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [{ id: 1, name: 'Ada' }]
})
Now any browser request to /api/users returns that fixed payload. This lets you test edge cases that are hard to trigger against a live backend, like an empty list, a 500 error, or a slow response.
To assert that the request happened, alias it with .as() and wait for it:
it('shows an error banner when the API fails', () => {
cy.intercept('GET', '/api/users', {
statusCode: 500,
body: { message: 'Server error' }
}).as('getUsers')
cy.visit('/dashboard')
cy.wait('@getUsers').its('response.statusCode').should('eq', 500)
cy.contains('Something went wrong').should('be.visible')
})
The cy.wait('@getUsers') line pauses the test until the intercepted request resolves, then hands you the request and response to assert on. Set up the intercept before the action that triggers the request, or it won’t catch anything. If you’re weighing when to fake a response versus mock a whole service, our posts on API mocking and API stubbing vs API mocking break down the tradeoffs.
When Cypress-for-API makes sense, and when it doesn’t
Cypress is a strong fit for API checks that live inside a UI test suite. If you’re already writing e2e tests and you need to seed data, verify an endpoint your UI depends on, or stub a response to test error handling, cy.request() and cy.intercept() keep everything in one place. No context switch, no second tool.
It’s also fine for a handful of standalone API smoke tests when Cypress is your only test runner and you don’t want to add another dependency.
Where it gets awkward is a large, standalone API test suite. Cypress was built for the browser, so API testing is a capability layered on top, not the core design. A few gaps show up as the suite grows:
- No visual request builder. Every request is code. Composing headers, bodies, and query params by hand gets slow when you have hundreds of endpoints.
- Weaker for a CI-only API pipeline. Cypress spins up a browser context even for pure API runs, which is overhead you don’t need.
- No shared API definition. The request shapes live in your test files, not in a spec your team can reuse across design, docs, and testing.
This is where an API-native tool fits better. Apidog is built around the API itself: you design an endpoint once, then build test scenarios against it with a visual request builder and visual assertions, no code required for the common cases. Requests, environments, and auth live in a shared workspace, so your team isn’t re-deriving them from test files.
For CI, the Apidog CLI runs your saved test scenarios headlessly in any step that can run Node:
npm install -g apidog-cli
apidog run \
--access-token "$APIDOG_ACCESS_TOKEN" \
-t <scenarioOrSuiteId> \
-e <environmentId> \
-r cli,html,junit
The -t flag targets a saved scenario or suite, -e selects an environment, and -r picks the report formats (cli, html, json, junit). The JUnit output plugs straight into most CI dashboards. Add -d or --iteration-data to drive a test from a data file, and --upload-report to push results back to your workspace. The CLI runs saved scenarios; it’s not an interactive request sender.
A useful way to think about it: use cy.request() for API checks that support your browser tests, and reach for an API-native tool when API testing is the main job. If you’re comparing options, our roundups of API test automation tools that run in CI/CD and the wider 30 best API testing tools cover the field.
FAQ
Does cy.request() run in the browser?
No. cy.request() runs from the Cypress Node process, outside the browser. That’s why it isn’t blocked by CORS or same-origin policy, and why cy.intercept() can’t intercept it. If you need a request that the browser makes, trigger it through your app and catch it with cy.intercept().
How do I test a 400 or 404 response without failing the test?
By default cy.request() fails on any non-2xx or non-3xx status. Set failOnStatusCode: false in the options object, then assert on the status yourself with expect(response.status).to.eq(404).
Can I use cy.request() to log in before a UI test?
Yes, and it’s a common pattern. Send a POST to your login endpoint with cy.request(), read the token from response.body, and store it (in Cypress.env(), localStorage, or a cookie) so your UI test starts already authenticated. This skips the login form and speeds up the suite.
What’s the difference between cy.request() and cy.intercept()?
cy.request() sends a real HTTP request and returns the actual response, so you use it to test an API. cy.intercept() watches or fakes the requests your front-end makes in the browser, so you use it to control what your UI receives. One hits the network; the other shapes it.
Should I use Cypress or a dedicated tool for API testing?
Use Cypress when API checks support a browser test suite you already run. For a large standalone API suite, CI-only API pipelines, or a shared API definition your whole team works from, an API-native tool like Apidog fits better. See our API testing best practices for how to decide.



