Skip to content

Commit 7993c37

Browse files
billyvgclaude
andcommitted
feat(developer-settings): Add webhook headers field to custom integrations
Add a Webhook Headers textarea to the Integration Details form for internal and public custom integrations, directly below Webhook URL. Each line is a 'Header-Name: value' pair sent with every outgoing webhook request. Mirrors the allowedOrigins field exactly (convert/extract multiline helpers). Masked values returned by the API round-trip safely because the backend preserves the stored value for any entry resubmitted with the mask sentinel, so the form needs no special masking logic. Supersedes #116732, which split out this UI ahead of the backend. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2d1b8e3 commit 7993c37

3 files changed

Lines changed: 71 additions & 0 deletions

File tree

static/app/types/integrations.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ export type SentryApp = {
246246
id: number;
247247
slug: string;
248248
};
249+
// Each entry is a "Header-Name: value" line. Values are masked by the API for
250+
// viewers without elevated access.
251+
webhookHeaders?: string[];
249252
};
250253

251254
// Minimal Sentry App representation for use with avatars

static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ describe('Sentry Application Details', () => {
124124
verifyInstall: true,
125125
isAlertable: true,
126126
allowedOrigins: [],
127+
webhookHeaders: [],
127128
schema: {},
128129
overview: '',
129130
};
@@ -136,6 +137,33 @@ describe('Sentry Application Details', () => {
136137
})
137138
);
138139
});
140+
141+
it('saves webhook headers', async () => {
142+
renderComponent();
143+
144+
await userEvent.type(screen.getByRole('textbox', {name: 'Name'}), 'Test App');
145+
await userEvent.type(screen.getByRole('textbox', {name: 'Author'}), 'Sentry');
146+
await userEvent.type(
147+
screen.getByRole('textbox', {name: 'Webhook URL'}),
148+
'https://webhook.com'
149+
);
150+
await userEvent.type(
151+
screen.getByRole('textbox', {name: 'Webhook Headers'}),
152+
'X-Example: value'
153+
);
154+
155+
await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
156+
157+
expect(createAppRequest).toHaveBeenCalledWith(
158+
'/sentry-apps/',
159+
expect.objectContaining({
160+
data: expect.objectContaining({
161+
webhookHeaders: ['X-Example: value'],
162+
}),
163+
method: 'POST',
164+
})
165+
);
166+
});
139167
});
140168

141169
describe('Creating a new internal Sentry App', () => {
@@ -229,6 +257,20 @@ describe('Sentry Application Details', () => {
229257
expect(screen.getByRole('textbox', {name: 'Client ID'})).toBeInTheDocument();
230258
expect(screen.getByRole('textbox', {name: 'Client Secret'})).toBeInTheDocument();
231259
});
260+
261+
it('prefills webhook headers from the app', async () => {
262+
sentryApp.webhookHeaders = ['X-Example: value', 'Another-Header: thing'];
263+
MockApiClient.addMockResponse({
264+
url: `/sentry-apps/${sentryApp.slug}/`,
265+
body: sentryApp,
266+
});
267+
268+
renderComponent();
269+
270+
expect(await screen.findByRole('textbox', {name: 'Webhook Headers'})).toHaveValue(
271+
'X-Example: value\nAnother-Header: thing'
272+
);
273+
});
232274
});
233275

234276
describe('Renders for internal apps', () => {

static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ const sentryAppFormSchema = z
9090
name: z.string(),
9191
author: z.string(),
9292
webhookUrl: z.string(),
93+
webhookHeaders: z.string(),
9394
redirectUrl: z.string(),
9495
verifyInstall: z.boolean(),
9596
isAlertable: z.boolean(),
@@ -201,6 +202,7 @@ type SaveSentryAppPayload = {
201202
author?: string | null;
202203
overview?: string;
203204
redirectUrl?: string;
205+
webhookHeaders?: string[];
204206
webhookUrl?: string;
205207
};
206208

@@ -518,6 +520,9 @@ function SentryApplicationForm({
518520
schema: getSchemaFieldValue(app?.schema),
519521
overview: app?.overview ?? '',
520522
allowedOrigins: convertMultilineFieldValue(app?.allowedOrigins ?? []),
523+
// Masked values (Header-Name: ***) round-trip safely: the backend preserves
524+
// the stored value for any entry resubmitted with the mask sentinel.
525+
webhookHeaders: convertMultilineFieldValue(app?.webhookHeaders ?? []),
521526
organization: organization.slug,
522527
isInternal,
523528
scopes: app ? [...app.scopes] : [],
@@ -557,6 +562,7 @@ function SentryApplicationForm({
557562
scopes: value.scopes,
558563
events: value.events,
559564
allowedOrigins: extractMultilineFields(value.allowedOrigins),
565+
webhookHeaders: extractMultilineFields(value.webhookHeaders),
560566
schema: value.schema.trim() === '' ? {} : JSON.parse(value.schema),
561567
// The author parser doesn't allow_blank, so send null for empty
562568
// (covers internal apps with no author).
@@ -693,6 +699,26 @@ function SentryApplicationForm({
693699
)}
694700
</form.AppField>
695701

702+
<form.AppField name="webhookHeaders">
703+
{field => (
704+
<field.Layout.Row
705+
label={t('Webhook Headers')}
706+
hintText={t(
707+
'Custom headers to include with every webhook request. Enter one header per line in the format: Header-Name: value'
708+
)}
709+
>
710+
<field.TextArea
711+
autosize
712+
value={field.state.value}
713+
onChange={field.handleChange}
714+
placeholder={
715+
'anthropic-version: 2023-06-01\nauthorization: Bearer token123'
716+
}
717+
/>
718+
</field.Layout.Row>
719+
)}
720+
</form.AppField>
721+
696722
{!isInternal && (
697723
<form.AppField name="redirectUrl">
698724
{field => (

0 commit comments

Comments
 (0)