How to buildOctober 12, 20267 min read
React Native + ExpoStep 6 of 9

Recipe: a list loaded from an API with pull-to-refresh in React Native

In short

In React Native a list from an API is built on FlatList: the refreshing + onRefresh pair adds the pull-to-refresh gesture, and onEndReached loads the next page. To survive a real network you also need separate loading, error and empty states, a synchronous flag against duplicate load-more calls, cancellation of stale requests with AbortController, and de-duplication by id. Below is a ready hook and screen for Expo SDK 57 with nine tests.

Code recipeLevel: IntermediateReact Native RecipesPractice: ~20–40 min

After this you will be able to

  • connect a FlatList to an API with separate states for first load, error, empty list and end of data
  • add pull-to-refresh with refreshing and onRefresh and page loading with onEndReached without repeated requests
  • cancel stale requests with AbortController and drop duplicates at page boundaries
  • verify the screen without a device: tsc, nine jest-expo tests with a mocked fetch and expo export

Verified with

Expo SDK 57 (expo 57.0.27)React Native 0.86.3React 19.2.3TypeScript 6.0.3react-native-safe-area-context 5.7.0jest-expo 57.0.5 + Jest 29.7@testing-library/react-native 14.1.0Node.js 24 LTS (24.18.0)

Checked against docs: October 12, 2026

Contents
  1. 01The task and the screen states
  2. 02The code: request, hook and screen
  3. 03How to plug it in
  4. 04Performance of a long list
  5. 05How to verify
  6. 06Limitations and alternatives

The task and the screen states

The "Services" screen loads a catalog from the server in pages of 20. The user pulls the list down to refresh it and scrolls to the end to load more. The lesson on APIs and storage covers fetch, caching and a single page; this recipe covers what it leaves out: paging and races between requests.

First load

What the user sees
A spinner and "Loading services…"
Where it comes from
No data yet, the request is in flight

Error, no data

What the user sees
The error text and "Retry"
Where it comes from
The first request failed

Empty

What the user sees
"No services yet"
Where it comes from
The server returned an empty items

Refreshing

What the user sees
The system indicator above the list
Where it comes from
The pull-to-refresh gesture

Refresh error

What the user sees
A banner above the old list
Where it comes from
Data is already there, so we keep it

Loading more

What the user sees
A spinner in the list footer
Where it comes from
Scrolling crossed the onEndReached threshold

Load-more error

What the user sees
A message and "Retry" in the footer
Where it comes from
The next page did not arrive

End of data

What the user sees
"No more services"
Where it comes from
The server returned nextCursor: null

The server returns a page and the cursor of the next one, an opaque string the client sends back as is. Cursor pagination is more robust than page numbers: if a record is inserted at the top between requests, a numbered page shifts and shows a record twice, while a cursor continues exactly where it stopped. We still drop duplicates at the boundary, because servers differ.

GET /v1/services?limit=20
{
  "items": [
    { "id": "41", "title": "Landing page", "price": 250000 },
    { "id": "42", "title": "Online store", "price": 900000 }
  ],
  "nextCursor": "eyJpZCI6IjQyIn0"
}
The response contract the recipe expects (sample data)

The code: request, hook and screen

The code is split into three files. The request knows the URL and response format, the usePagedList hook knows only the cursor, states and cancellation, and the screen only renders. The hook knows nothing about "services" and works for orders or news just as well.

src/api/services.ts
import type { Page } from '../hooks/usePagedList';

const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com';
const PAGE_SIZE = 20;

export type Service = { id: string; title: string; price: number };

export class HttpError extends Error {
  constructor(public readonly status: number) {
    super(`HTTP ${status}`);
    this.name = 'HttpError';
  }
}

export async function fetchServicesPage(
  cursor: string | null,
  signal: AbortSignal,
): Promise<Page<Service>> {
  let query = `limit=${PAGE_SIZE}`;
  if (cursor) query += `&cursor=${encodeURIComponent(cursor)}`;

  const response = await fetch(`${API_URL}/v1/services?${query}`, {
    headers: { Accept: 'application/json' },
    signal,
  });
  // fetch does not reject on 404/500, so check the status yourself
  if (!response.ok) throw new HttpError(response.status);
  return (await response.json()) as Page<Service>;
}
Fetching a page: cursor, response.ok check, abort signal
src/hooks/usePagedList.ts
import { useCallback, useEffect, useRef, useState } from 'react';

// one page of results and the cursor of the next one (null means no more)
export type Page<T> = { items: T[]; nextCursor: string | null };
export type FetchPage<T> = (cursor: string | null, signal: AbortSignal) => Promise<Page<T>>;
type Mode = 'loading' | 'refreshing' | 'loadingMore';

// the data may shift between requests, so never show the same record twice
function mergeById<T extends { id: string }>(prev: T[], next: T[]): T[] {
  const seen = new Set(prev.map((item) => item.id));
  return [...prev, ...next.filter((item) => !seen.has(item.id))];
}

export function usePagedList<T extends { id: string }>(fetchPage: FetchPage<T>) {
  const [items, setItems] = useState<T[]>([]);
  const [mode, setMode] = useState<Mode | 'idle'>('loading');
  const [error, setError] = useState<unknown>(null); // first page or refresh
  const [moreError, setMoreError] = useState<unknown>(null); // loading more
  const [hasMore, setHasMore] = useState(false);

  const cursorRef = useRef<string | null>(null);
  const requestRef = useRef<AbortController | null>(null);
  // a synchronous flag: two onEndReached calls can arrive before state updates
  const busyRef = useRef(false);

  const run = useCallback(
    async (next: Mode) => {
      requestRef.current?.abort(); // a refresh cancels loading more instead of waiting for it
      const controller = new AbortController();
      requestRef.current = controller;
      busyRef.current = true;
      setMode(next);
      const append = next === 'loadingMore';

      try {
        const page = await fetchPage(append ? cursorRef.current : null, controller.signal);
        if (controller.signal.aborted) return; // a stale response must not overwrite a newer one
        setItems((prev) => (append ? mergeById(prev, page.items) : page.items));
        cursorRef.current = page.nextCursor;
        setHasMore(page.nextCursor !== null);
        setError(null);
        setMoreError(null);
      } catch (e) {
        if (controller.signal.aborted) return;
        if (append) setMoreError(e);
        else setError(e);
      } finally {
        if (requestRef.current === controller) {
          requestRef.current = null;
          busyRef.current = false;
          setMode('idle');
        }
      }
    },
    [fetchPage],
  );

  useEffect(() => {
    run('loading');
    return () => {
      requestRef.current?.abort(); // the screen is gone, so is the need for the request
      requestRef.current = null;
    };
  }, [run]);

  const refresh = useCallback(() => run('refreshing'), [run]);
  const retry = useCallback(() => run('loading'), [run]);

  const loadMore = useCallback(() => {
    // after a load-more error wait for "Retry", or scrolling will loop requests
    if (busyRef.current || cursorRef.current === null || moreError) return;
    run('loadingMore');
  }, [run, moreError]);

  const retryMore = useCallback(() => {
    setMoreError(null);
    run('loadingMore');
  }, [run]);

  return { items, mode, error, moreError, hasMore, refresh, retry, loadMore, retryMore };
}
The paged list hook: refresh, load more, cancellation and duplicate protection
src/screens/ServicesList.tsx
import { memo } from 'react';
import {
  ActivityIndicator,
  Button,
  FlatList,
  StyleSheet,
  Text,
  View,
  type ListRenderItem,
} from 'react-native';
import { fetchServicesPage, HttpError, type Service } from '../api/services';
import { usePagedList } from '../hooks/usePagedList';

function errorText(e: unknown): string {
  if (e instanceof HttpError) return `Server responded with ${e.status}`;
  return 'Cannot reach the server';
}

const ServiceRow = memo(function ServiceRow({ item }: { item: Service }) {
  return (
    <View style={styles.row} accessible>
      <Text style={styles.title}>{item.title}</Text>
      <Text style={styles.muted}>from {item.price.toLocaleString('en-US')} KZT</Text>
    </View>
  );
});

// outside the component: the same function on every render
const renderItem: ListRenderItem<Service> = ({ item }) => <ServiceRow item={item} />;
const keyExtractor = (item: Service) => item.id;

export default function ServicesList() {
  const { items, mode, error, moreError, hasMore, refresh, retry, loadMore, retryMore } =
    usePagedList(fetchServicesPage);

  if (mode === 'loading' && items.length === 0) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" accessibilityLabel="Loading" />
        <Text style={styles.muted}>Loading services…</Text>
      </View>
    );
  }

  if (error && items.length === 0) {
    return (
      <View style={styles.center}>
        <Text style={styles.error}>{errorText(error)}</Text>
        <Button title="Retry" onPress={retry} />
      </View>
    );
  }

  let footer = null;
  if (mode === 'loadingMore') {
    footer = <ActivityIndicator style={styles.footer} accessibilityLabel="Loading more" />;
  } else if (moreError) {
    footer = (
      <View style={styles.footer}>
        <Text style={styles.error}>Could not load more: {errorText(moreError)}</Text>
        <Button title="Retry" onPress={retryMore} />
      </View>
    );
  } else if (!hasMore && items.length > 0) {
    footer = <Text style={[styles.muted, styles.footer]}>No more services</Text>;
  }

  return (
    <FlatList
      testID="services-list"
      data={items}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      ListHeaderComponent={
        error ? <Text style={styles.banner}>{errorText(error)}. Showing earlier data</Text> : null
      }
      ListEmptyComponent={<Text style={[styles.muted, styles.footer]}>No services yet</Text>}
      ListFooterComponent={footer}
      refreshing={mode === 'refreshing'}
      onRefresh={refresh}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
    />
  );
}

const styles = StyleSheet.create({
  center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, padding: 24 },
  row: { paddingVertical: 12, paddingHorizontal: 16, borderBottomWidth: StyleSheet.hairlineWidth },
  title: { fontSize: 16, fontWeight: '600' },
  muted: { color: '#666' },
  error: { color: '#b00020', textAlign: 'center' },
  banner: { backgroundColor: '#fff4ce', padding: 12 },
  footer: { padding: 16, alignItems: 'center', gap: 8 },
});
The screen: every state from the table, pull-to-refresh and load more
  • onEndReached is called once per content length, but fires again when the length changes (a spinner appears in the footer) or the user scrolls away from the edge and back. busyRef is a synchronous flag: setMode only takes effect on the next render, while a ref check stops a second request for the same page immediately.
  • Every request gets its own AbortController, and a new request cancels the previous one. A pull-to-refresh during load-more does not wait for it, it aborts it. After await the hook checks signal.aborted once more, so a response that arrives after cancellation never reaches the list.
  • After a load-more error, loadMore stays silent until the user taps "Retry". Otherwise every scroll touch near the end would send another request into a dead network.
  • A refresh error does not wipe the list: the data stays and a banner appears on top. The full error screen is only for when there is nothing to show.
  • fetchPage must be stable: declare it outside the component like fetchServicesPage, or wrap it in useCallback. A new function on every render restarts the effect and the first load.

How to plug it in

  1. Create a project or open yours

    The recipe is verified on the blank-typescript template. The list needs no new dependencies: FlatList, RefreshControl and fetch ship with React Native.

  2. Add a safe area

    React Native's own SafeAreaView is deprecated, and the docs point to react-native-safe-area-context. Expo Router already sets up the provider; in the blank template install it with npx expo install.

  3. Set the API URL

    Expo inlines EXPO_PUBLIC_* variables into the bundle as plain text. An API URL is fine there, secret keys are not.

  4. Copy the three files and render the screen

    Put the files from the previous section into src/ and render ServicesList at the app root or in an Expo Router route.

bash
npx create-expo-app@latest services-app --template blank-typescript
cd services-app
npx expo install react-native-safe-area-context
The project and the only extra dependency
.env
EXPO_PUBLIC_API_URL=https://api.example.com
The API URL is not a secret and can be embedded in the app
App.tsx
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import ServicesList from './src/screens/ServicesList';

export default function App() {
  return (
    <SafeAreaProvider>
      <SafeAreaView style={{ flex: 1 }}>
        <ServicesList />
      </SafeAreaView>
      <StatusBar style="auto" />
    </SafeAreaProvider>
  );
}
The app root with a safe area

Performance of a long list

FlatList does not mount every row, only a "window" around the visible area; off-screen rows are rendered asynchronously, so a very fast scroll can briefly show blank space. The React Native docs list the settings that affect this. The recipe already does the main thing: the row is wrapped in memo, and renderItem and keyExtractor are declared outside the component so they are not recreated on every render.

onEndReachedThreshold

Default
2, two screens before the end
When to change it
The recipe uses 0.5: loading starts half a screen before the end. Higher means earlier, with a risk of extra requests

initialNumToRender

Default
10
When to change it
If 10 rows do not fill a tablet screen, the first frame shows blank space

windowSize

Default
21: 10 screens above, 10 below and the current one
When to change it
Lower saves memory but makes blank areas more likely

maxToRenderPerBatch

Default
10
When to change it
Higher means fewer blank areas but a busier JS thread and slower response to taps

getItemLayout

Default
not set
When to change it
Only when every row has the same height. With dynamic font size the height changes and the calculation becomes wrong

extraData

Default
not set
When to change it
When renderItem depends on something other than data, such as a selected id. FlatList is a PureComponent and will not re-render without it

In our experience a list is slow more often because of heavy rows than because of settings: full-size images instead of thumbnails, nested containers, computations in renderItem. Make the row lighter first and tune the numbers after.

How to verify

All state logic can be checked without a phone: the jest-expo preset mocks the native part of the Expo SDK, and fetch is replaced with jest.fn() in tests. For scrolling and the refresh gesture, React Native Testing Library 14.1 has user.scrollTo and user.pullToRefresh; to make FlatList "see" the end, pass contentSize and layoutMeasurement to scrollTo.

bash
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev
Test dependencies: --dev is an Expo CLI flag, the packages go to devDependencies
package.json
{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "preset": "jest-expo",
    "transformIgnorePatterns": [
      "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
    ]
  }
}
A package.json fragment: the script and Jest setup from the Expo docs
tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "types": ["jest"]
  }
}
TypeScript 6 no longer includes @types packages by default: without types, tsc does not know test and expect
__tests__/ServicesList.test.tsx
import { act, fireEvent, render, screen, userEvent } from '@testing-library/react-native';
import ServicesList from '../src/screens/ServicesList';

const items = (...ids: number[]) =>
  ids.map((n) => ({ id: String(n), title: `Service ${n}`, price: 10000 * n }));
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });

// a response the test releases by hand, to observe the intermediate state
function deferred() {
  let resolve!: (value: unknown) => void;
  const promise = new Promise((r) => (resolve = r));
  return { promise, resolve };
}

const fetchMock = jest.fn();
beforeEach(() => {
  fetchMock.mockReset();
  globalThis.fetch = fetchMock;
});

test('first load: spinner, then the list', async () => {
  const first = deferred();
  fetchMock.mockReturnValueOnce(first.promise);
  await render(<ServicesList />);
  expect(screen.getByText('Loading services…')).toBeOnTheScreen();

  first.resolve(ok({ items: items(1, 2), nextCursor: null }));
  expect(await screen.findByText('Service 1')).toBeOnTheScreen();
  expect(screen.getByText('No more services')).toBeOnTheScreen();
  expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/v1/services?limit=20');
});

test('empty response → empty state', async () => {
  fetchMock.mockResolvedValueOnce(ok({ items: [], nextCursor: null }));
  await render(<ServicesList />);
  expect(await screen.findByText('No services yet')).toBeOnTheScreen();
});

test('first load error → Retry', async () => {
  fetchMock
    .mockRejectedValueOnce(new TypeError('Network request failed'))
    .mockResolvedValueOnce(ok({ items: items(1), nextCursor: null }));
  await render(<ServicesList />);
  expect(await screen.findByText('Cannot reach the server')).toBeOnTheScreen();

  await fireEvent.press(screen.getByText('Retry'));
  expect(await screen.findByText('Service 1')).toBeOnTheScreen();
});

test('scrolling to the end loads the next page by cursor, without duplicates', async () => {
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1, 2, 3), nextCursor: 'c2' }))
    .mockResolvedValueOnce(ok({ items: items(3, 4, 5), nextCursor: null }));
  await render(<ServicesList />);
  await screen.findByText('Service 3');

  const user = userEvent.setup();
  await user.scrollTo(screen.getByTestId('services-list'), {
    y: 500,
    contentSize: { width: 400, height: 1000 },
    layoutMeasurement: { width: 400, height: 600 },
  });

  expect(await screen.findByText('No more services')).toBeOnTheScreen();
  expect(fetchMock).toHaveBeenCalledTimes(2);
  expect(fetchMock.mock.calls[1][0]).toContain('cursor=c2');
  expect(screen.getAllByText('Service 3')).toHaveLength(1); // the duplicate at the page boundary is dropped
  expect(screen.getByText('Service 5')).toBeOnTheScreen();
});

test('a second onEndReached while loading sends no second request', async () => {
  const second = deferred();
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1, 2), nextCursor: 'c2' }))
    .mockReturnValueOnce(second.promise);
  await render(<ServicesList />);
  await screen.findByText('Service 2');

  const list = screen.getByTestId('services-list');
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  expect(fetchMock).toHaveBeenCalledTimes(2); // first page + one load-more

  second.resolve(ok({ items: items(3), nextCursor: null }));
  expect(await screen.findByText('Service 3')).toBeOnTheScreen();
});

test('pull-to-refresh replaces the list, a refresh error keeps the data', async () => {
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1, 2), nextCursor: null }))
    .mockResolvedValueOnce(ok({ items: items(7), nextCursor: null }))
    .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) });
  await render(<ServicesList />);
  await screen.findByText('Service 1');

  const user = userEvent.setup();
  await user.pullToRefresh(screen.getByTestId('services-list'));
  expect(await screen.findByText('Service 7')).toBeOnTheScreen();
  expect(screen.queryByText('Service 1')).not.toBeOnTheScreen();

  await user.pullToRefresh(screen.getByTestId('services-list'));
  expect(
    await screen.findByText('Server responded with 500. Showing earlier data'),
  ).toBeOnTheScreen();
  expect(screen.getByText('Service 7')).toBeOnTheScreen();
});

test('a refresh cancels loading more, its late response never reaches the list', async () => {
  const stale = deferred();
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1), nextCursor: 'c2' }))
    .mockReturnValueOnce(stale.promise)
    .mockResolvedValueOnce(ok({ items: items(8), nextCursor: null }));
  await render(<ServicesList />);
  await screen.findByText('Service 1');

  const list = screen.getByTestId('services-list');
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  const user = userEvent.setup();
  await user.pullToRefresh(list);
  expect(await screen.findByText('Service 8')).toBeOnTheScreen();
  expect(fetchMock.mock.calls[1][1].signal.aborted).toBe(true);

  await act(async () => {
    stale.resolve(ok({ items: items(99), nextCursor: null })); // the response arrives after the cancel
  });
  expect(screen.queryByText('Service 99')).not.toBeOnTheScreen();
});

test('load-more error: a footer button, scrolling does not retry by itself', async () => {
  fetchMock
    .mockResolvedValueOnce(ok({ items: items(1), nextCursor: 'c2' }))
    .mockRejectedValueOnce(new TypeError('Network request failed'))
    .mockResolvedValueOnce(ok({ items: items(2), nextCursor: null }));
  await render(<ServicesList />);
  await screen.findByText('Service 1');

  const list = screen.getByTestId('services-list');
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  expect(await screen.findByText('Could not load more: Cannot reach the server')).toBeOnTheScreen();
  await fireEvent(list, 'endReached', { distanceFromEnd: 0 });
  expect(fetchMock).toHaveBeenCalledTimes(2); // waits for an explicit Retry

  await fireEvent.press(screen.getByText('Retry'));
  expect(await screen.findByText('Service 2')).toBeOnTheScreen();
});

test('leaving the screen cancels the request', async () => {
  fetchMock.mockReturnValueOnce(new Promise(() => {}));
  await render(<ServicesList />);
  const { signal } = fetchMock.mock.calls[0][1] as { signal: AbortSignal };
  expect(signal.aborted).toBe(false);

  await screen.unmount();
  expect(signal.aborted).toBe(true);
});
Nine tests: states, cursor, duplicates, repeated onEndReached, refresh, cancellation
terminal
$ npx tsc --noEmit
$ npx jest --verbose
PASS __tests__/ServicesList.test.tsx
  √ first load: spinner, then the list (426 ms)
  √ empty response → empty state (3 ms)
  √ first load error → Retry (27 ms)
  √ scrolling to the end loads the next page by cursor, without duplicates (109 ms)
  √ a second onEndReached while loading sends no second request (60 ms)
  √ pull-to-refresh replaces the list, a refresh error keeps the data (31 ms)
  √ a refresh cancels loading more, its late response never reaches the list (14 ms)
  √ load-more error: a footer button, scrolling does not retry by itself (7 ms)
  √ leaving the screen cancels the request (1 ms)
Tests:       9 passed, 9 total
$ npx expo export --platform android --platform ios
iOS Bundled 3557ms index.ts (590 modules)
Android Bundled 5466ms index.ts (592 modules)
Exported: dist
$ npx expo-doctor
21/21 checks passed. No issues detected!
A run in a temporary project: types, tests, Android and iOS bundles, expo-doctor

The tests were checked in reverse: remove busyRef, the signal.aborted check or mergeById from the hook and the matching test fails. They do not replace a device, so run through a short list on a phone.

  • Turn on airplane mode and open the screen: an error and "Retry"; after reconnecting, the list
  • Pull the list down: the refresh indicator shows and disappears after the response
  • Scroll to the end several times in a row: the server log shows one load-more per page
  • Turn off the network before scrolling to the end: an error in the footer, scrolling sends no requests, "Retry" loads the page
  • Leave the screen while loading: the request is cancelled, no warnings in the console
  • Turn on VoiceOver or TalkBack: a row is read as a whole, spinners are announced by their label

Limitations and alternatives

  • No cache across launches and no offline mode: after a restart the list loads again. Saving the last response in AsyncStorage is shown in the lesson on APIs and storage.
  • No automatic retries with backoff, only a manual "Retry". Automatic retries with exponential delay make sense when failures are short-lived and the server can take it.
  • Cancellation only works if fetchPage passes signal on to fetch. If you use your own HTTP client wrapper, make sure it does not drop it.
  • With many list screens, TanStack Query (useInfiniteQuery) handles the same set of tasks: caching, retries, paging, refetch on app focus. It was not verified in this recipe; the hand-written hook helps you understand the mechanics and avoids a dependency for a single screen.
  • For very long lists with complex rows there are third-party virtualized list implementations. Switch after measuring, not up front.
How this was verified4 · passed1 · not runchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.