Watch a live product demo đź‘€ See how Sanity powers richer commerce experiences

Custom Input Component with Webhook

By Andy Fitzgerald

Custom input component with a DIY webhook for connecting to APIs beyond publish, update, and delete events.

Custom input component

import React from 'react';
import { FormField } from '@sanity/base/components';
import { TextInput, Stack, Text, Flex, Button, Box, useToast } from '@sanity/ui';
import PatchEvent, {set, unset} from '@sanity/form-builder/PatchEvent';
import { useId } from "@reach/auto-id";

const LDHyperlink = React.forwardRef((props, ref) => {

  const { 
    type,         // Schema information
    value,        // Current field value
    readOnly,     // Boolean if field is not editable
    placeholder,  // Placeholder text from the schema
    markers,      // Markers including validation rules
    presence,     // Presence information for collaborative avatars
    compareValue, // Value to check for "edited" functionality
    onFocus,      // Method to handle focus state
    onBlur,       // Method to handle blur state  
    onChange,     // Method to handle patch events
    parent,       // Parent document data
  } = props

  // Webhook payload. If you need to specify particular keys—-such as `event-type` for GitHub Actions workflows--those can be added here. 
  const webHookData = {
    link: value,  
    resourceId: parent._id

  const inputId = useId();
  const toast = useToast();

  const apiBaseURL = process.env.SANITY_STUDIO_DEV_API_URL || '';

  const webHook = () =>
      `${apiBaseURL}/ld`, // URL to which to POST the webhook 
        method: 'POST',
        headers: {
          'User-Agent': 'UXMethods'
        body: JSON.stringify(webHookData)
    ).then(response => {
      if (response.ok) {
        console.log("Webhook successfully received.");
          status: 'info',
          title: 'Linked Data received',
          closable: true
      } else {
        return Promise.reject(response);
    }).catch(err => {
      console.warn('There was a problem', err);
        status: 'error',
        title: 'There was a problem:',
        description: 'The Linked Data request failed. Check the console for error messages.',
        closable: true

  // Creates a change handler for patching data
  const handleChange = React.useCallback(
    (event) => {
      const inputValue = event.currentTarget.value // get current value
      // if the value exists, set the data, if not, unset the data
      onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()))

  const isURL = (str) => {
    const pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol
      '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
      '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
      '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
      '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
      '(\\#[-a-z\\d_]*)?$','i'); // fragment locator
    return !!pattern.test(str);

  return (
    <Stack space={1}>
        __unstable_markers={markers}    // Handles all markers including validation
        __unstable_presence={presence}  // Handles presence avatars
        compareValue={compareValue}     // Handles "edited" status
        inputId={inputId}               // Allows the label to connect to the input field
          <Box flex={[1]}>
              id={inputId}                  // A unique ID for this input
              onChange={handleChange}       // A function to call when the input value changes
              value={value}                 // Current field value
              readOnly={readOnly}           // If "readOnly" is defined make this field read only
              placeholder={placeholder}     // If placeholder is defined, display placeholder text
              onFocus={onFocus}             // Handles focus events
              onBlur={onBlur}               // Handles blur events
          <Box marginLeft={[1]}>
              text="Get Linked Data"
              disabled={!isURL(value)}      // Button disables until a valid URL is entered
              onClick={() => {
                  status: 'info',
                  title: 'Linked Data request sent.',
                  closable: true

export default LDHyperlink

Sanity GROQ powered webhooks are awesome, but sometimes you may want to trigger a microservice outside of a publish, update, or delete event. This custom input component adds ad-hoc webhook functionality to trigger a webhook ready API with field data as part of its payload. In this case, I'm using a custom API running on a subdomain to crawl and fetch linked data from a URL, in order to more easily populate fields for a shareable resource.

The end result of this component (and its connected service) is similar to Espen Hovlandsdal's URL Metadata Input component, but I wanted a bit more control over which linked data I fetched and how, with the idea being that this can later fit into a larger Linked Data pipeline. If you're just looking to populate metadata, Espen's plugin may be simpler.

Security caveats: Since this webhook is entirely on the front end (and in the repo), be sure not to include any API keys or passwords. I initially set this up with a GitHub Action, but wasn't able to secure the access token in a way I was comfortable with, so I put my API on a subdomain where I could control CORS access. If you host on Netlify, you may have access to "secrets" through an environment variable that would allow you to connect to services more easily.


Other schemas by author